익스클루시브

Two-Key <mv-two-key>

중요한 작업(프로덕션 삭제, 송금, 배포)을 위한 보호 장치로, 의도적이고 동기화된 두 개의 “열쇠”로만 활성화됩니다. 원통이 스프링과 함께 90도 돌아가는 회전식 키 스위치 두 개와, 반투명 덮개 아래에 숨겨진 보호된 액션 버튼으로 구성됩니다.

문화적 레퍼런스

위험한 게임, 존 바담 (1983, 영화). 미사일을 발사하려면 두 장교가 동시에 두 개의 키를 돌려야 하며, 키는 한 사람이 둘 다 닿을 수 없을 만큼 멀리 떨어져 있습니다. UI에서는 의도적이고 동기화된 두 번의 키 회전, 또는 두 번째 승인자가 돌린 키가 있어야만 중요한 작업이 잠금 해제됩니다.

작동 방식

중요한 작업(프로덕션 삭제, 송금, 배포)을 위한 보호 장치로, 의도적이고 동기화된 두 개의 “키”로만 활성화됩니다. 스프링으로 실린더가 4분의 1 회전하는 두 개의 회전식 키 스위치와, 반투명 덮개 아래 숨겨진 보호된 작업 버튼으로 구성됩니다. sync 모드: 두 키를 서로 짧은 시간 안에 돌려야 합니다. 먼저 돌린 키는 카운트다운 링과 함께 고정되고, 두 손가락이나 두 키 키보드 코드(F + J)로 둘을 한 번에 돌릴 수 있으며, 시간 안에 돌리지 못하면 “Out of sync”와 함께 모두 초기화됩니다. two-person 모드: 내 키가 두 번째 승인자를 요청하고(mv-request → approve(name) / reject()) “Waiting for a second approver…”를 표시합니다. 둘 다 돌려지면 덮개가 몇 초 동안 작업 버튼에서 들어 올려지며(줄어드는 도화선), Escape 또는 시간 초과 시 해제됩니다. 키 잠금을 풀기 전에 리소스 이름을 입력하도록 요구할 수도 있습니다.

카테고리폼
유형Web Component (<mv-two-key>)
상태안정
키트안전한 파괴적 작업과 비밀 정보
Keywordsexclusive, 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

설치

node scripts/add.mjs two-key --out ./src/marvelous

Marvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["two-key"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

복사되는 파일(의존성 포함): 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.

사용법

빠른 시작, 동작하는 가장 작은 마크업:

<mv-two-key confirm-text="orders-prod-db">
  <button slot="action">Delete database</button>
</mv-two-key>

기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:

<div id="tk-demo" style="width:min(100%,62rem);margin-inline:auto">
  <style>
    #tk-demo { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(20rem, 100%), 1fr)); gap:1.25rem; align-items:start }
    #tk-demo .tk-card { display:grid; grid-template-columns:minmax(0, 1fr); 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 &amp; 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>

API

Attributes

Name유형DefaultDescription
modesync | two-personsyncsync: 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()).
windownumber (ms)3000sync: 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”.
armednumber (ms)5000How long the action stays unlocked once both keys are turned (min 1000). Lapsing disarms (mv-abort reason "timeout").
chordstringF Jsync: 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-textstringAdds 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-labelstringConfirmLabel of the built-in action button, used when no slot="action" is provided.
label-a / label-bstringKey 1 / Key 2 (two-person: Your key / Second approver)Visible labels (and accessible names) of the two keys.
done-labelstringDoneMessage shown (and announced) after the action fired.
disabledbooleanLocks both keys and the action. Setting it mid-sequence disarms (mv-abort reason "disabled").
data-stateidle | locked | latched | waiting | armed | fired | out-of-sync | rejectedSet by the component (fired, out-of-sync and rejected return to idle after ~1.8s).

Properties

Name유형Description
statestringRead-only: current state (see data-state).
turned{ a: boolean, b: boolean }Read-only: which keys are turned.
approverstring | nullRead-only (two-person): name given to approve().
stringsPartial<Record<string, string>>Overrides for every visible text and announcement, English defaults, {placeholders} filled in (confirmLabel, locked, deckSync, deckTwoPerson, key1, key2, keyYours, keySecond, turned, ready, approved, waitingShort, notRequested, statusIdle, statusIdleTwo, statusLocked, statusLatched, statusWaiting, statusArmed, approvedBy, outOfSync, declined, declinedWhy, timedOut, disarmed, cancelled, count). label-a, label-b, action-label and done-label still win over their defaults.

Methods

NameDescription
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

NameDescription
mv-turnA key turned or un-turned. detail: { key: "a" | "b", turned, source?, approver? } (source: pointer | keyboard | chord | click | api).
mv-requesttwo-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-armBoth keys are turned: the action is unlocked. detail: { approver, expires (ms) }.
mv-launchThe 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-abortA sequence stopped. detail: { reason: "out-of-sync" | "timeout" | "escape" | "cancelled" | "released" | "rejected" | "disabled" | custom, detail? }.

Content structure

NameDescription
actionThe 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

NameDescription
mv-two-key-confirm / -input / -needTyped confirmation: label, field, expected text.
mv-two-key-deckPanel holding the two keys and the link between them (role=group).
mv-two-key-keyOne key (data-key a | b, data-turned, data-latched, data-remote, data-waiting).
mv-two-key-switch / -dial / -barrel / -slot / -ring / -marksThe rotary switch: button, dial, rotating barrel with its key slot, countdown ring, off/on marks.
mv-two-key-caption / -label / -kbd / -whoKey caption: label, keyboard cap or approver name.
mv-two-key-linkConnector whose halves fill as each key turns; its node unlocks when armed.
mv-two-key-guard / -lid / -fuseThe guarded action, its hinged frosted lid and the armed-time fuse.
mv-two-key-status / -countStatus line (role=status inside, data-tone neutral | progress | armed | danger | success) and the ticking countdown chip (aria-hidden).

CSS variables

NameDefaultDescription
--mv-two-key-colorvar(--mv-accent)Turned keys, rings, link and fuse.
--mv-two-key-dangervar(--mv-danger)Out of sync / declined feedback.
--mv-two-key-size5.5remDiameter of each key dial.
--mv-two-key-durationvar(--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. Forced colors: bezels, barrels and links use CanvasText, a turned key and the armed link switch to Highlight, the marks and slot stay visible. Known limits: the component only orchestrates the UI, so dual control must be enforced on the server; the chord needs a physical keyboard that reports two keys held at once (some keyboards drop simultaneous keys), and the two-finger turn needs a multi-touch screen.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고