save.ts

8.7 KB · raw

1import { detectHomograph, extractDomain } from "../shared/domain";2import { ACCENT_COLORS } from "../shared/types";3import type { VaultEntry } from "../shared/types";45let accentHex = "#E8956D";6chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string } } | undefined) => {7  const name = resp?.data?.accentColor ?? "peach";8  accentHex = ACCENT_COLORS.find(c => c.name === name)?.hex ?? "#E8956D";9});1011let currentBanner: (() => void) | null = null;1213interface DetectedCredential {14  username: string;15  password: string;16  url: string;17  domain: string;18}1920async function getMatchingEntry(domain: string): Promise<VaultEntry | null> {21  const response = await chrome.runtime.sendMessage({22    type: "AUTOFILL_QUERY",23    payload: { domain },24  });25  const summary = (response as {26    ok: boolean;27    data?: Array<{ id: string }>;28  }).data?.[0];2930  if (!summary) return null;3132  const entryResponse = await chrome.runtime.sendMessage({33    type: "GET_ENTRY_FOR_FILL",34    payload: { id: summary.id },35  });3637  return (38    (entryResponse as { ok: boolean; data?: VaultEntry }).data ?? null39  );40}4142async function saveEntry(43  username: string,44  password: string,45  url: string46): Promise<void> {47  await chrome.runtime.sendMessage({48    type: "ADD_ENTRY",49    payload: { title: new URL(url).hostname, username, password, url },50  });51}5253async function updateEntryPassword(54  id: string,55  password: string56): Promise<void> {57  await chrome.runtime.sendMessage({58    type: "UPDATE_ENTRY",59    payload: { id, patch: { password } },60  });61}6263function showBanner(64  credential: DetectedCredential,65  existingEntry: VaultEntry | null,66  onPrimaryAction: () => Promise<void>,67  onDismiss: () => void68): () => void {69  const host = document.createElement("div");70  host.style.cssText = `71    position: fixed; top: 16px; right: 16px; z-index: 2147483647;72    font-family: Inter, sans-serif;73  `;74  host.style.setProperty("--peach-accent", accentHex);75  const shadow = host.attachShadow({ mode: "closed" });7677  const isUpdate = existingEntry !== null;78  const title = isUpdate ? "Update password?" : "Save password?";79  const subtitle = isUpdate80    ? `Update password for ${credential.domain}?`81    : `Save password for ${credential.domain}?`;8283  const style = shadow.appendChild(document.createElement("style"));84  style.textContent = `85    :host { all: initial; }86    .banner {87      background:88        radial-gradient(120% 80% at 0% 0%, color-mix(in srgb, var(--peach-accent) 10%, transparent) 0%, transparent 65%),89        radial-gradient(85% 100% at 100% 100%, color-mix(in srgb, var(--peach-accent) 6%, transparent) 0%, transparent 70%),90        #0C0A06;91      border: 1px solid rgba(240,232,216,0.10);92      border-radius: 14px;93      padding: 14px 16px;94      box-shadow: 0 18px 48px rgba(0,0,0,0.45);95      width: 296px;96      font-family: Inter, sans-serif;97    }98    .banner-title {99      font-family: "Cormorant Garamond", Georgia, serif;100      font-size: 22px;101      font-style: italic;102      line-height: 0.95;103      color: #F0E8D8;104      margin: 0 0 6px;105    }106    .banner-sub {107      font-family: "IBM Plex Mono", "SFMono-Regular", monospace;108      font-size: 10px;109      letter-spacing: 0.08em;110      color: rgba(240,232,216,0.42);111      margin: 0 0 12px;112    }113    .banner-warn {114      font-family: "IBM Plex Mono", "SFMono-Regular", monospace;115      font-size: 10px;116      line-height: 1.4;117      color: #C8A840;118      margin: 0 0 12px;119    }120    .banner-actions {121      display: flex;122      gap: 8px;123    }124    .btn-save {125      flex: 1;126      background: var(--peach-accent);127      color: rgba(0,0,0,0.75);128      border: none;129      border-radius: 10px;130      padding: 8px 12px;131      font-size: 10px;132      letter-spacing: 0.08em;133      text-transform: uppercase;134      font-weight: 500;135      cursor: pointer;136      font-family: Inter, sans-serif;137    }138    .btn-save:hover { opacity: 0.88; }139    .btn-dismiss {140      flex: 1;141      background: rgba(240,232,216,0.06);142      color: rgba(240,232,216,0.6);143      border: 1px solid rgba(240,232,216,0.1);144      border-radius: 10px;145      padding: 8px 12px;146      font-size: 10px;147      letter-spacing: 0.08em;148      text-transform: uppercase;149      cursor: pointer;150      font-family: Inter, sans-serif;151    }152    .btn-dismiss:hover { background: rgba(240,232,216,0.1); }153  `;154155  const banner = shadow.appendChild(document.createElement("div"));156  banner.className = "banner";157158  const titleEl = banner.appendChild(document.createElement("p"));159  titleEl.className = "banner-title";160  titleEl.textContent = title;161162  const subEl = banner.appendChild(document.createElement("p"));163  subEl.className = "banner-sub";164  subEl.textContent = subtitle;165166  // UI-8 / LV-2: non-blocking homograph warning when the page's own address167  // is non-ASCII or punycode — never blocks saving.168  if (detectHomograph(location.hostname)) {169    const warnEl = banner.appendChild(document.createElement("p"));170    warnEl.className = "banner-warn";171    warnEl.textContent =172      "⚠ This site's address contains non-ASCII or punycode characters. Verify it's the site you intend.";173  }174175  const actionsEl = banner.appendChild(document.createElement("div"));176  actionsEl.className = "banner-actions";177178  const saveBtn = actionsEl.appendChild(document.createElement("button"));179  saveBtn.className = "btn-save";180  saveBtn.textContent = isUpdate ? "Update" : "Save";181182  const dismissBtn = actionsEl.appendChild(document.createElement("button"));183  dismissBtn.className = "btn-dismiss";184  dismissBtn.textContent = "Dismiss";185186  saveBtn.addEventListener("click", async () => {187    saveBtn.disabled = true;188    saveBtn.textContent = isUpdate ? "Updating..." : "Saving...";189    try {190      await onPrimaryAction();191      host.remove();192    } finally {193      saveBtn.disabled = false;194      saveBtn.textContent = isUpdate ? "Update" : "Save";195    }196  });197198  dismissBtn.addEventListener("click", () => {199    onDismiss();200    host.remove();201  });202203  document.body.appendChild(host);204205  return () => {206    host.remove();207  };208}209210async function checkForNewCredentials(211  credential: DetectedCredential212): Promise<void> {213  if (currentBanner) {214    currentBanner();215    currentBanner = null;216  }217218  const existing = await getMatchingEntry(credential.domain);219  const isUpdate = existing !== null && existing.password !== credential.password;220  const isNew = existing === null;221222  if (!isUpdate && !isNew) {223    if (existing && existing.canary && existing.password === credential.password) {224      chrome.runtime.sendMessage({225        type: "FIRE_CANARY_PING",226        payload: {227          webhookUrl: existing.canary.webhookUrl,228          entryTitle: existing.title,229          trigger: "submitted",230        },231      }).catch(() => {});232      chrome.runtime.sendMessage({233        type: "UPDATE_ENTRY",234        payload: {235          id: existing.id,236          patch: {237            canary: {238              ...existing.canary,239              lastPinged: Date.now(),240              pingCount: (existing.canary.pingCount ?? 0) + 1,241            },242          },243        },244      }).catch(() => {});245    }246    return;247  }248249  currentBanner = showBanner(250    credential,251    isUpdate ? existing : null,252    async () => {253      if (isUpdate && existing) {254        await updateEntryPassword(existing.id, credential.password);255      } else {256        await saveEntry(credential.username, credential.password, credential.url);257      }258      currentBanner = null;259    },260    () => {261      currentBanner = null;262    }263  );264}265266export function setupSaveDetection(): void {267  // PCH-01-026 #1: removed a dead no-op URL-tracking MutationObserver that ran268  // on every DOM mutation only to update a `lastUrl` variable nothing consumed.269  document.addEventListener("submit", async (e) => {270    const form = e.target as HTMLFormElement;271    if (!form) return;272273    const inputs = Array.from(form.querySelectorAll("input"));274    let username = "";275    let password = "";276277    for (const input of inputs) {278      const type = input.type.toLowerCase();279      const name = (input.name + input.id + input.autocomplete).toLowerCase();280281      if (type === "password" && input.value) {282        password = input.value;283      } else if (284        !username &&285        (type === "text" || type === "email" || type === "tel") &&286        (name.includes("user") ||287          name.includes("email") ||288          name.includes("login") ||289          name.includes("name") ||290          input.autocomplete === "username" ||291          input.autocomplete === "email")292      ) {293        username = input.value;294      }295    }296297    if (!password) return;298299    const domain = extractDomain(location.href) ?? location.hostname;300    const credential: DetectedCredential = {301      username,302      password,303      url: location.href,304      domain,305    };306307    await checkForNewCredentials(credential);308  });309}