save.ts

7.7 KB · raw

1import { extractDomain } from "../shared/domain";2import type { VaultEntry } from "../shared/types";34let currentBanner: (() => void) | null = null;56interface DetectedCredential {7  username: string;8  password: string;9  url: string;10  domain: string;11}1213async function getMatchingEntry(domain: string): Promise<VaultEntry | null> {14  const response = await chrome.runtime.sendMessage({15    type: "AUTOFILL_QUERY",16    payload: { domain },17  });18  const summary = (response as {19    ok: boolean;20    data?: Array<{ id: string }>;21  }).data?.[0];2223  if (!summary) return null;2425  const entryResponse = await chrome.runtime.sendMessage({26    type: "GET_ENTRY_FOR_FILL",27    payload: { id: summary.id },28  });2930  return (31    (entryResponse as { ok: boolean; data?: VaultEntry }).data ?? null32  );33}3435async function saveEntry(36  username: string,37  password: string,38  url: string39): Promise<void> {40  await chrome.runtime.sendMessage({41    type: "ADD_ENTRY",42    payload: { title: new URL(url).hostname, username, password, url },43  });44}4546async function updateEntryPassword(47  id: string,48  password: string49): Promise<void> {50  await chrome.runtime.sendMessage({51    type: "UPDATE_ENTRY",52    payload: { id, patch: { password } },53  });54}5556function showBanner(57  credential: DetectedCredential,58  existingEntry: VaultEntry | null,59  onPrimaryAction: () => Promise<void>,60  onDismiss: () => void61): () => void {62  const host = document.createElement("div");63  host.style.cssText = `64    position: fixed; top: 16px; right: 16px; z-index: 2147483647;65    font-family: Inter, sans-serif;66  `;67  const shadow = host.attachShadow({ mode: "closed" });6869  const isUpdate = existingEntry !== null;70  const title = isUpdate ? "Update password?" : "Save password?";71  const subtitle = isUpdate72    ? `Update password for ${credential.domain}?`73    : `Save password for ${credential.domain}?`;7475  const style = shadow.appendChild(document.createElement("style"));76  style.textContent = `77    :host { all: initial; }78    .banner {79      background:80        radial-gradient(120% 80% at 0% 0%, rgba(217,104,48,0.10) 0%, transparent 65%),81        radial-gradient(85% 100% at 100% 100%, rgba(217,104,48,0.06) 0%, transparent 70%),82        #0C0A06;83      border: 1px solid rgba(240,232,216,0.10);84      border-radius: 14px;85      padding: 14px 16px;86      box-shadow: 0 18px 48px rgba(0,0,0,0.45);87      width: 296px;88      font-family: Inter, sans-serif;89    }90    .banner-title {91      font-family: "Cormorant Garamond", Georgia, serif;92      font-size: 22px;93      font-style: italic;94      line-height: 0.95;95      color: #F0E8D8;96      margin: 0 0 6px;97    }98    .banner-sub {99      font-family: "IBM Plex Mono", "SFMono-Regular", monospace;100      font-size: 10px;101      letter-spacing: 0.08em;102      color: rgba(240,232,216,0.42);103      margin: 0 0 12px;104    }105    .banner-actions {106      display: flex;107      gap: 8px;108    }109    .btn-save {110      flex: 1;111      background: #D96830;112      color: rgba(0,0,0,0.75);113      border: none;114      border-radius: 10px;115      padding: 8px 12px;116      font-size: 10px;117      letter-spacing: 0.08em;118      text-transform: uppercase;119      font-weight: 500;120      cursor: pointer;121      font-family: Inter, sans-serif;122    }123    .btn-save:hover { opacity: 0.88; }124    .btn-dismiss {125      flex: 1;126      background: rgba(240,232,216,0.06);127      color: rgba(240,232,216,0.6);128      border: 1px solid rgba(240,232,216,0.1);129      border-radius: 10px;130      padding: 8px 12px;131      font-size: 10px;132      letter-spacing: 0.08em;133      text-transform: uppercase;134      cursor: pointer;135      font-family: Inter, sans-serif;136    }137    .btn-dismiss:hover { background: rgba(240,232,216,0.1); }138  `;139140  const banner = shadow.appendChild(document.createElement("div"));141  banner.className = "banner";142143  const titleEl = banner.appendChild(document.createElement("p"));144  titleEl.className = "banner-title";145  titleEl.textContent = title;146147  const subEl = banner.appendChild(document.createElement("p"));148  subEl.className = "banner-sub";149  subEl.textContent = subtitle;150151  const actionsEl = banner.appendChild(document.createElement("div"));152  actionsEl.className = "banner-actions";153154  const saveBtn = actionsEl.appendChild(document.createElement("button"));155  saveBtn.className = "btn-save";156  saveBtn.textContent = isUpdate ? "Update" : "Save";157158  const dismissBtn = actionsEl.appendChild(document.createElement("button"));159  dismissBtn.className = "btn-dismiss";160  dismissBtn.textContent = "Dismiss";161162  saveBtn.addEventListener("click", async () => {163    saveBtn.disabled = true;164    saveBtn.textContent = isUpdate ? "Updating..." : "Saving...";165    try {166      await onPrimaryAction();167      host.remove();168    } finally {169      saveBtn.disabled = false;170      saveBtn.textContent = isUpdate ? "Update" : "Save";171    }172  });173174  dismissBtn.addEventListener("click", () => {175    onDismiss();176    host.remove();177  });178179  document.body.appendChild(host);180181  return () => {182    host.remove();183  };184}185186async function checkForNewCredentials(187  credential: DetectedCredential188): Promise<void> {189  if (currentBanner) {190    currentBanner();191    currentBanner = null;192  }193194  const existing = await getMatchingEntry(credential.domain);195  const isUpdate = existing !== null && existing.password !== credential.password;196  const isNew = existing === null;197198  if (!isUpdate && !isNew) {199    if (existing && existing.canary && existing.password === credential.password) {200      chrome.runtime.sendMessage({201        type: "FIRE_CANARY_PING",202        payload: {203          webhookUrl: existing.canary.webhookUrl,204          entryTitle: existing.title,205          trigger: "submitted",206        },207      }).catch(() => {});208      chrome.runtime.sendMessage({209        type: "UPDATE_ENTRY",210        payload: {211          id: existing.id,212          patch: {213            canary: {214              ...existing.canary,215              lastPinged: Date.now(),216              pingCount: (existing.canary.pingCount ?? 0) + 1,217            },218          },219        },220      }).catch(() => {});221    }222    return;223  }224225  currentBanner = showBanner(226    credential,227    isUpdate ? existing : null,228    async () => {229      if (isUpdate && existing) {230        await updateEntryPassword(existing.id, credential.password);231      } else {232        await saveEntry(credential.username, credential.password, credential.url);233      }234      currentBanner = null;235    },236    () => {237      currentBanner = null;238    }239  );240}241242export function setupSaveDetection(): void {243  let lastUrl = location.href;244245  new MutationObserver(() => {246    if (location.href !== lastUrl) {247      lastUrl = location.href;248    }249  }).observe(document.body, { childList: true, subtree: true });250251  document.addEventListener("submit", async (e) => {252    const form = e.target as HTMLFormElement;253    if (!form) return;254255    const inputs = Array.from(form.querySelectorAll("input"));256    let username = "";257    let password = "";258259    for (const input of inputs) {260      const type = input.type.toLowerCase();261      const name = (input.name + input.id + input.autocomplete).toLowerCase();262263      if (type === "password" && input.value) {264        password = input.value;265      } else if (266        !username &&267        (type === "text" || type === "email" || type === "tel") &&268        (name.includes("user") ||269          name.includes("email") ||270          name.includes("login") ||271          name.includes("name") ||272          input.autocomplete === "username" ||273          input.autocomplete === "email")274      ) {275        username = input.value;276      }277    }278279    if (!password) return;280281    const domain = extractDomain(location.href) ?? location.hostname;282    const credential: DetectedCredential = {283      username,284      password,285      url: location.href,286      domain,287    };288289    await checkForNewCredentials(credential);290  });291}