├── source ├── full │ ├── types.ts │ ├── styles │ │ └── full.sass │ ├── services │ │ ├── init.ts │ │ ├── log.ts │ │ ├── notifications.ts │ │ ├── vaults.ts │ │ ├── disabledDomains.ts │ │ └── credentials.ts │ ├── index.pug │ ├── components │ │ ├── pages │ │ │ ├── connect │ │ │ │ ├── index.tsx │ │ │ │ ├── CodeInput.tsx │ │ │ │ └── ConnectPage.tsx │ │ │ ├── AttributionsPage.tsx │ │ │ ├── NotificationsPage.tsx │ │ │ └── saveCredentials │ │ │ │ ├── CredentialsSelector.tsx │ │ │ │ └── index.tsx │ │ ├── Layout.tsx │ │ └── App.tsx │ ├── applications │ │ └── full.tsx │ └── hooks │ │ ├── credentials.ts │ │ ├── disabledDomains.ts │ │ ├── document.ts │ │ └── vaultContents.ts ├── typings │ ├── globals.d.ts │ └── assets.d.ts ├── tab │ ├── library │ │ ├── disable.ts │ │ ├── frames.ts │ │ ├── zIndex.ts │ │ ├── styles.ts │ │ ├── page.ts │ │ ├── position.ts │ │ ├── dismount.ts │ │ └── resize.ts │ ├── state │ │ ├── frame.ts │ │ └── form.ts │ ├── index.ts │ ├── services │ │ ├── log.ts │ │ ├── init.ts │ │ ├── logins │ │ │ ├── disabled.ts │ │ │ ├── saving.ts │ │ │ └── watcher.ts │ │ ├── config.ts │ │ ├── autoLogin.ts │ │ ├── messaging.ts │ │ ├── formDetection.ts │ │ ├── LoginTracker.ts │ │ └── form.ts │ ├── types.ts │ └── ui │ │ ├── saveDialog.ts │ │ └── popup.ts ├── shared │ ├── library │ │ ├── version.ts │ │ ├── i18n.ts │ │ ├── url.ts │ │ ├── log.ts │ │ ├── buffer.ts │ │ ├── error.ts │ │ ├── otp.ts │ │ ├── clone.ts │ │ ├── vaultTypes.ts │ │ ├── domain.ts │ │ └── extension.ts │ ├── extension.ts │ ├── services │ │ ├── notifications.ts │ │ └── messaging.ts │ ├── symbols.ts │ ├── notifications │ │ ├── index.ts │ │ └── pages │ │ │ └── WelcomeV3.tsx │ ├── hooks │ │ ├── timer.ts │ │ ├── theme.ts │ │ ├── config.ts │ │ ├── global.ts │ │ └── async.ts │ ├── styles │ │ ├── fonts.sass │ │ └── base.sass │ ├── components │ │ ├── ThemeProvider.tsx │ │ ├── ErrorMessage.tsx │ │ ├── loading │ │ │ └── BusyLoader.tsx │ │ ├── RouteError.tsx │ │ ├── ErrorBoundary.tsx │ │ └── ConfirmDialog.tsx │ ├── queries │ │ └── config.ts │ ├── i18n │ │ └── trans.ts │ └── themes.ts ├── background │ ├── index.ts │ ├── library │ │ └── domain.ts │ ├── services │ │ ├── log.ts │ │ ├── entry.ts │ │ ├── desktop │ │ │ ├── header.ts │ │ │ └── request.ts │ │ ├── tabs.ts │ │ ├── disabledDomains.ts │ │ ├── autoLogin.ts │ │ ├── crypto.ts │ │ ├── notifications.ts │ │ ├── storage │ │ │ └── BrowserStorageInterface.ts │ │ ├── config.ts │ │ ├── init.ts │ │ ├── storage.ts │ │ ├── recents.ts │ │ ├── cryptoKeys.ts │ │ └── loginMemory.ts │ └── types.ts └── popup │ ├── types.ts │ ├── state │ └── app.ts │ ├── hooks │ ├── document.ts │ ├── tab.ts │ ├── credentials.ts │ └── otp.ts │ ├── services │ ├── log.ts │ ├── init.ts │ ├── reset.ts │ ├── recents.ts │ ├── entry.ts │ ├── clipboard.ts │ └── tab.ts │ ├── index.pug │ ├── applications │ └── popup.tsx │ ├── styles │ └── popup.sass │ ├── queries │ ├── loginMemory.ts │ ├── disabledDomains.ts │ └── desktop.ts │ └── components │ ├── vaults │ ├── VaultStateIndicator.tsx │ ├── VaultItemList.tsx │ └── VaultItem.tsx │ ├── contexts │ └── LaunchContext.tsx │ ├── otps │ ├── OTPItemList.tsx │ └── OTPItem.tsx │ ├── entries │ ├── EntryItemList.tsx │ ├── EntryInfoDialog.tsx │ └── EntryItem.tsx │ ├── pages │ ├── AboutPage.tsx │ └── VaultsPage.tsx │ └── App.tsx ├── .prettierrc ├── chrome-extension.jpg ├── chrome-extension-2.jpg ├── resources ├── buttercup-128.png ├── buttercup-16.png ├── buttercup-256.png ├── buttercup-48.png ├── OpenSans-Regular.woff2 ├── providers │ ├── file-256.png │ ├── dropbox-256.png │ ├── local-256.png │ ├── webdav-256.png │ ├── nextcloud-256.png │ ├── owncloud-256.png │ ├── googledrive-256.png │ ├── mybuttercup-256.png │ └── webdav-white-256.png ├── OpenSans-SemiBold.woff2 ├── buttercup-simple-150.png ├── content-button-background.png ├── full.pug ├── popup.pug ├── manifest.v2.json └── manifest.v3.json ├── .gitignore ├── .editorconfig ├── .vscode └── settings.json ├── tsconfig.json ├── scripts └── version.js ├── .github └── workflows │ └── test.yml ├── LICENSE └── package.json /source/full/types.ts: -------------------------------------------------------------------------------- 1 | export * from "../shared/types.js"; 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 4, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /chrome-extension.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/chrome-extension.jpg -------------------------------------------------------------------------------- /chrome-extension-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/chrome-extension-2.jpg -------------------------------------------------------------------------------- /source/typings/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare var BROWSER: "chrome" | "edge" | "firefox"; 2 | declare var browser: typeof chrome; 3 | -------------------------------------------------------------------------------- /resources/buttercup-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/buttercup-128.png -------------------------------------------------------------------------------- /resources/buttercup-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/buttercup-16.png -------------------------------------------------------------------------------- /resources/buttercup-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/buttercup-256.png -------------------------------------------------------------------------------- /resources/buttercup-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/buttercup-48.png -------------------------------------------------------------------------------- /resources/OpenSans-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/OpenSans-Regular.woff2 -------------------------------------------------------------------------------- /resources/providers/file-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/providers/file-256.png -------------------------------------------------------------------------------- /resources/OpenSans-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/OpenSans-SemiBold.woff2 -------------------------------------------------------------------------------- /resources/buttercup-simple-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/buttercup-simple-150.png -------------------------------------------------------------------------------- /resources/providers/dropbox-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/providers/dropbox-256.png -------------------------------------------------------------------------------- /resources/providers/local-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/providers/local-256.png -------------------------------------------------------------------------------- /resources/providers/webdav-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/providers/webdav-256.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | .history 4 | node_modules 5 | /dist 6 | /release 7 | Archive.zip 8 | dist.zip 9 | /secrets.json 10 | -------------------------------------------------------------------------------- /resources/providers/nextcloud-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/providers/nextcloud-256.png -------------------------------------------------------------------------------- /resources/providers/owncloud-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/providers/owncloud-256.png -------------------------------------------------------------------------------- /resources/content-button-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/content-button-background.png -------------------------------------------------------------------------------- /resources/providers/googledrive-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/providers/googledrive-256.png -------------------------------------------------------------------------------- /resources/providers/mybuttercup-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/providers/mybuttercup-256.png -------------------------------------------------------------------------------- /resources/providers/webdav-white-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttercup/buttercup-browser-extension/HEAD/resources/providers/webdav-white-256.png -------------------------------------------------------------------------------- /source/tab/library/disable.ts: -------------------------------------------------------------------------------- 1 | export function itemIsIgnored(element: HTMLElement): boolean { 2 | return element.matches("[data-bcupignore=true] *, [data-bcupignore=true]"); 3 | } 4 | -------------------------------------------------------------------------------- /source/tab/state/frame.ts: -------------------------------------------------------------------------------- 1 | import { createStateObject } from "obstate"; 2 | 3 | export const FRAME = createStateObject<{ 4 | isTop: boolean; 5 | }>({ 6 | isTop: false 7 | }); 8 | -------------------------------------------------------------------------------- /source/shared/library/version.ts: -------------------------------------------------------------------------------- 1 | // Do not edit this file - it is generated automatically at build time 2 | 3 | export const BUILD_DATE = "2024-04-09"; 4 | export const VERSION = "3.2.0"; 5 | -------------------------------------------------------------------------------- /source/shared/extension.ts: -------------------------------------------------------------------------------- 1 | export function getExtensionAPI(): typeof chrome { 2 | if (BROWSER === "firefox") { 3 | return browser; 4 | } 5 | return self.chrome || self["browser"]; 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 4 8 | 9 | [{.prettierrc,package.json,package-lock.json,.babelrc}] 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /source/background/index.ts: -------------------------------------------------------------------------------- 1 | import { initialise } from "./services/init.js"; 2 | import { log } from "./services/log.js"; 3 | 4 | initialise().catch((err) => { 5 | console.error(err); 6 | log("initialisation failed"); 7 | }); 8 | -------------------------------------------------------------------------------- /source/popup/types.ts: -------------------------------------------------------------------------------- 1 | export enum DesktopConnectionState { 2 | Connected = "connected", 3 | Error = "error", 4 | NotConnected = "notConnected", 5 | Pending = "pending" 6 | } 7 | 8 | export * from "../shared/types.js"; 9 | -------------------------------------------------------------------------------- /source/shared/library/i18n.ts: -------------------------------------------------------------------------------- 1 | export function getLanguage(/*preferences: Preferences, locale: string*/): string { 2 | // return preferences.language || locale || DEFAULT_LANGUAGE; 3 | return window.navigator.language; 4 | } 5 | -------------------------------------------------------------------------------- /source/popup/state/app.ts: -------------------------------------------------------------------------------- 1 | import { createStateObject } from "obstate"; 2 | import { PopupPage } from "../types.js"; 3 | 4 | export const APP_STATE = createStateObject<{ 5 | tab: PopupPage; 6 | }>({ 7 | tab: PopupPage.Entries 8 | }); 9 | -------------------------------------------------------------------------------- /source/tab/library/frames.ts: -------------------------------------------------------------------------------- 1 | export function findIframeForWindow(url: string): HTMLIFrameElement | null { 2 | const iframes = [...document.getElementsByTagName("iframe")]; 3 | return iframes.find((frame) => frame.src === url) || null; 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "vsicons.presets.angular": false, 3 | "eslint.enable": false, 4 | "files.associations": { 5 | "**/*.js": "javascriptreact" 6 | }, 7 | "typescript.preferences.importModuleSpecifierEnding": "js" 8 | } 9 | -------------------------------------------------------------------------------- /source/full/styles/full.sass: -------------------------------------------------------------------------------- 1 | html, body 2 | margin: 0 3 | padding: 0 4 | height: 100% 5 | 6 | #root 7 | display: flex 8 | justify-content: center 9 | min-height: 100% 10 | height: 100% 11 | 12 | @import ../../shared/styles/base 13 | -------------------------------------------------------------------------------- /source/background/library/domain.ts: -------------------------------------------------------------------------------- 1 | import { UsedCredentials } from "../types.js"; 2 | 3 | export function extractDomainFromCredentials(credentials: UsedCredentials): string | null { 4 | const match = /^https?:\/\/([^\/]+)/.exec(credentials.url); 5 | return match ? match[1] : null; 6 | } 7 | -------------------------------------------------------------------------------- /source/shared/library/url.ts: -------------------------------------------------------------------------------- 1 | export function formatURL(base: string): string { 2 | if (/^\d+\.\d+\.\d+\.\d+/.test(base)) { 3 | return `http://${base}`; 4 | } else if (/^https?:\/\//i.test(base) === false) { 5 | return `https://${base}`; 6 | } 7 | return base; 8 | } 9 | -------------------------------------------------------------------------------- /source/shared/services/notifications.ts: -------------------------------------------------------------------------------- 1 | import { Position, Toaster, ToasterInstance } from "@blueprintjs/core"; 2 | 3 | const __toaster = Toaster.create({ 4 | position: Position.BOTTOM_RIGHT 5 | }); 6 | 7 | export function getToaster(): ToasterInstance { 8 | return __toaster; 9 | } 10 | -------------------------------------------------------------------------------- /source/shared/symbols.ts: -------------------------------------------------------------------------------- 1 | export const API_KEY_ALGO = "ECDH"; 2 | export const API_KEY_CURVE = "P-256"; 3 | 4 | export const BRAND_COLOUR = "#00B7AC"; 5 | export const BRAND_COLOUR_DARK = "#179E94"; 6 | 7 | export const DESKTOP_API_PORT = 12822; 8 | 9 | export const MESSAGE_DEFAULT_TIMEOUT = 15000; 10 | -------------------------------------------------------------------------------- /source/shared/notifications/index.ts: -------------------------------------------------------------------------------- 1 | import { TITLE as WelcomeV3Title, Page as WelcomeV3Page } from "./pages/WelcomeV3.jsx"; 2 | 3 | export const NOTIFICATIONS: Record JSX.Element]> = { 4 | "2024-03-welcome-v3": [WelcomeV3Title, WelcomeV3Page] 5 | }; 6 | 7 | export const NOTIFICATION_NAMES = Object.keys(NOTIFICATIONS); 8 | -------------------------------------------------------------------------------- /source/tab/index.ts: -------------------------------------------------------------------------------- 1 | import { FRAME } from "./state/frame.js"; 2 | import { initialise } from "./services/init.js"; 3 | import { log } from "./services/log.js"; 4 | 5 | FRAME.isTop = window.parent === window; 6 | 7 | initialise().catch((err) => { 8 | console.error(err); 9 | log(`initialisation failed: ${err.message}`); 10 | }); 11 | -------------------------------------------------------------------------------- /source/tab/services/log.ts: -------------------------------------------------------------------------------- 1 | import { Logger, createLog } from "../../shared/library/log.js"; 2 | 3 | const LOG_NAME = "buttercup:browser:tab"; 4 | 5 | let __logger: Logger; 6 | 7 | export function log(...args: Array): void { 8 | if (!__logger) { 9 | __logger = createLog(LOG_NAME, true); 10 | } 11 | return __logger(...args); 12 | } 13 | -------------------------------------------------------------------------------- /source/popup/hooks/document.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export function useBodyClass(className: string): void { 4 | const body = document.body; 5 | useEffect(() => { 6 | body.classList.add(className); 7 | return () => { 8 | body.classList.remove(className); 9 | }; 10 | }, [className]); 11 | } 12 | -------------------------------------------------------------------------------- /source/popup/services/log.ts: -------------------------------------------------------------------------------- 1 | import { Logger, createLog } from "../../shared/library/log.js"; 2 | 3 | const LOG_NAME = "buttercup:browser:popup"; 4 | 5 | let __logger: Logger; 6 | 7 | export function log(...args: Array): void { 8 | if (!__logger) { 9 | __logger = createLog(LOG_NAME, true); 10 | } 11 | return __logger(...args); 12 | } 13 | -------------------------------------------------------------------------------- /source/full/services/init.ts: -------------------------------------------------------------------------------- 1 | import { initialise as initialiseI18n } from "../../shared/i18n/trans.js"; 2 | import { getLanguage } from "../../shared/library/i18n.js"; 3 | import { log } from "./log.js"; 4 | 5 | export async function initialise() { 6 | log("initialising"); 7 | await initialiseI18n(getLanguage()); 8 | log("initialisation complete"); 9 | } 10 | -------------------------------------------------------------------------------- /source/shared/hooks/timer.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { DependencyList } from "react"; 3 | 4 | export function useTimer(callback: () => void, delay: number, dependencies: DependencyList) { 5 | useEffect(() => { 6 | const timer = setInterval(callback, delay); 7 | return () => clearInterval(timer); 8 | }, dependencies); 9 | } 10 | -------------------------------------------------------------------------------- /source/shared/library/log.ts: -------------------------------------------------------------------------------- 1 | import { createLog as createLogger, toggleContext } from "gle"; 2 | 3 | export type Logger = ReturnType; 4 | 5 | export function createLog(name: string, force: boolean = false): (...args: Array) => void { 6 | if (force) { 7 | toggleContext(name, true); 8 | } 9 | return createLogger(name); 10 | } 11 | -------------------------------------------------------------------------------- /source/full/index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title Buttercup 5 | meta(charset="utf-8") 6 | link(rel="icon", type="image/png", href=require("../../resources/buttercup-256.png").default) 7 | link(rel="stylesheet" href="./styles/full.sass") 8 | script(defer, src="./applications/full.tsx") 9 | body 10 | div#root 11 | -------------------------------------------------------------------------------- /source/full/services/log.ts: -------------------------------------------------------------------------------- 1 | import { createLog } from "../../shared/library/log.js"; 2 | 3 | const LOG_NAME = "buttercup:browser:page"; 4 | 5 | let __logger: ReturnType; 6 | 7 | export function log(...args: Array): void { 8 | if (!__logger) { 9 | __logger = createLog(LOG_NAME, true); 10 | } 11 | return __logger(...args); 12 | } 13 | -------------------------------------------------------------------------------- /source/background/services/log.ts: -------------------------------------------------------------------------------- 1 | import { createLog } from "../../shared/library/log.js"; 2 | 3 | const LOG_NAME = "buttercup:browser:background"; 4 | 5 | let __logger: ReturnType; 6 | 7 | export function log(...args: Array): void { 8 | if (!__logger) { 9 | __logger = createLog(LOG_NAME, true); 10 | } 11 | return __logger(...args); 12 | } 13 | -------------------------------------------------------------------------------- /source/popup/index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title Menu ⋅ Buttercup 5 | meta(charset="utf-8") 6 | link(rel="icon", type="image/png", href=require("../../resources/buttercup-256.png").default) 7 | link(rel="stylesheet" href="./styles/popup.sass") 8 | script(defer, src="./applications/popup.tsx") 9 | body 10 | div#root 11 | -------------------------------------------------------------------------------- /source/full/components/pages/connect/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ConnectPage as InternalPage } from "./ConnectPage.js"; 3 | import { ErrorBoundary } from "../../../../shared/components/ErrorBoundary.jsx"; 4 | 5 | export function ConnectPage() { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /source/shared/styles/fonts.sass: -------------------------------------------------------------------------------- 1 | @font-face 2 | font-family: "OpenSans" 3 | src: url("../../../resources/OpenSans-Regular.woff2") format("woff2") 4 | font-weight: normal 5 | font-style: normal 6 | 7 | @font-face 8 | font-family: "OpenSans" 9 | src: url("../../../resources/OpenSans-SemiBold.woff2") format("woff2") 10 | font-weight: bold 11 | font-style: normal 12 | -------------------------------------------------------------------------------- /source/typings/assets.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.md" { 2 | const value: any; 3 | export default value; 4 | } 5 | declare module "*.png" { 6 | const value: any; 7 | export default value; 8 | } 9 | declare module "*.jpg" { 10 | const value: any; 11 | export default value; 12 | } 13 | declare module "*.svg" { 14 | const value: any; 15 | export default value; 16 | } 17 | -------------------------------------------------------------------------------- /source/popup/services/init.ts: -------------------------------------------------------------------------------- 1 | import { initialise as initialiseI18n } from "../../shared/i18n/trans.js"; 2 | import { getLanguage } from "../../shared/library/i18n.js"; 3 | import { log } from "./log.js"; 4 | 5 | export async function initialise() { 6 | log("initialising"); 7 | global.popup = true; 8 | await initialiseI18n(getLanguage()); 9 | log("initialisation complete"); 10 | } 11 | -------------------------------------------------------------------------------- /source/tab/state/form.ts: -------------------------------------------------------------------------------- 1 | import { createStateObject } from "obstate"; 2 | import { LoginTarget } from "@buttercup/locust"; 3 | 4 | export const FORM = createStateObject<{ 5 | currentFormID: string | null; 6 | currentLoginTarget: LoginTarget | null; 7 | targetFormID: string | null; 8 | }>({ 9 | currentFormID: null, 10 | currentLoginTarget: null, 11 | targetFormID: null 12 | }); 13 | -------------------------------------------------------------------------------- /source/tab/services/init.ts: -------------------------------------------------------------------------------- 1 | import { initialise as initialiseMessaging } from "./messaging.js"; 2 | import { initialise as initialiseForms } from "./form.js"; 3 | import { initialise as initialiseCredentialsWatching } from "./logins/watcher.js"; 4 | 5 | export async function initialise() { 6 | await initialiseMessaging(); 7 | await initialiseForms(); 8 | await initialiseCredentialsWatching(); 9 | } 10 | -------------------------------------------------------------------------------- /resources/full.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title Buttercup 5 | meta(charset="utf-8") 6 | link(rel="icon", type="image/png", href=require("./buttercup-256.png").default) 7 | link(rel="stylesheet" href="full.css") 8 | link(rel="stylesheet" href="vendors.css") 9 | script(defer, src="full.js") 10 | script(defer, src="vendors.js") 11 | body 12 | div#root 13 | -------------------------------------------------------------------------------- /resources/popup.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title Menu ⋅ Buttercup 5 | meta(charset="utf-8") 6 | link(rel="icon", type="image/png", href=require("./buttercup-256.png").default) 7 | link(rel="stylesheet" href="popup.css") 8 | link(rel="stylesheet" href="vendors.css") 9 | script(defer, src="popup.js") 10 | script(defer, src="vendors.js") 11 | body 12 | div#root 13 | -------------------------------------------------------------------------------- /source/full/applications/full.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { App } from "../components/App.js"; 4 | import { initialise } from "../services/init.js"; 5 | 6 | initialise() 7 | .then(() => { 8 | ReactDOM.render( 9 | , 10 | document.getElementById("root"), 11 | ); 12 | }) 13 | .catch(err => { 14 | console.error(err); 15 | }); 16 | -------------------------------------------------------------------------------- /source/popup/applications/popup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { App } from "../components/App.js"; 4 | import { initialise } from "../services/init.js"; 5 | 6 | initialise() 7 | .then(() => { 8 | ReactDOM.render( 9 | , 10 | document.getElementById("root") 11 | ); 12 | }) 13 | .catch(err => { 14 | console.error(err); 15 | }); 16 | -------------------------------------------------------------------------------- /source/tab/types.ts: -------------------------------------------------------------------------------- 1 | import { InputType } from "../shared/types.js"; 2 | 3 | export * from "../shared/types.js"; 4 | 5 | export interface FrameEvent { 6 | formID?: string; 7 | inputDetails?: { 8 | otp?: string; 9 | password?: string; 10 | username?: string; 11 | }; 12 | inputType?: InputType; 13 | type: FrameEventType; 14 | } 15 | 16 | export enum FrameEventType { 17 | FillForm = "fillForm" 18 | } 19 | -------------------------------------------------------------------------------- /source/full/hooks/credentials.ts: -------------------------------------------------------------------------------- 1 | import { useAsync } from "../../shared/hooks/async.js"; 2 | import { getCredentials } from "../services/credentials.js"; 3 | import { UsedCredentials } from "../types.js"; 4 | 5 | export function useCapturedCredentials(): [ 6 | credentials: Array, 7 | loading: boolean, 8 | error: Error | null 9 | ] { 10 | const { error, loading, value } = useAsync(getCredentials, []); 11 | return [value || [], loading, error]; 12 | } 13 | -------------------------------------------------------------------------------- /source/background/services/entry.ts: -------------------------------------------------------------------------------- 1 | import { SearchResult } from "buttercup"; 2 | import { createNewTab } from "../../shared/library/extension.js"; 3 | import { formatURL } from "../../shared/library/url.js"; 4 | 5 | export async function openEntryPageInNewTab(_: SearchResult, url: string): Promise { 6 | const tab = await createNewTab(formatURL(url)); 7 | if (typeof tab?.id !== "number") { 8 | throw new Error("No tab ID for created tab"); 9 | } 10 | return tab.id; 11 | } 12 | -------------------------------------------------------------------------------- /source/popup/services/reset.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { sendBackgroundMessage } from "../../shared/services/messaging.js"; 3 | import { BackgroundMessageType } from "../types.js"; 4 | 5 | export async function resetApplicationSettings(): Promise { 6 | const resp = await sendBackgroundMessage({ 7 | type: BackgroundMessageType.ResetSettings 8 | }); 9 | if (resp.error) { 10 | throw new Layerr(resp.error, "Failed resetting settings"); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /source/tab/library/zIndex.ts: -------------------------------------------------------------------------------- 1 | export function findBestZIndexInContainer(parentElement: HTMLElement): number { 2 | let highest: number = 0; 3 | [...parentElement.children].forEach((child) => { 4 | const { zIndex } = window.getComputedStyle(child); 5 | if (zIndex) { 6 | const num = parseInt(zIndex, 10); 7 | if (!isNaN(num) && num > highest) { 8 | highest = num; 9 | } 10 | } 11 | }); 12 | return highest + 1; 13 | } 14 | -------------------------------------------------------------------------------- /source/background/types.ts: -------------------------------------------------------------------------------- 1 | export * from "../shared/types.js"; 2 | 3 | export enum LocalStorageItem { 4 | APIClientID = "bcup:api:clientID", 5 | APIPrivateKey = "bcup:api:privateKey", 6 | APIPublicKey = "bcup:api:publicKey", 7 | APIServerPublicKey = "bcup:api:serverPublicKey" 8 | } 9 | 10 | export enum SyncStorageItem { 11 | Configuration = "bcup:configuration", 12 | DisabledDomains = "bcup:disabledDomains", 13 | Notifications = "bcup:notifications", 14 | RecentItems = "bcup:recents" 15 | } 16 | -------------------------------------------------------------------------------- /source/full/hooks/disabledDomains.ts: -------------------------------------------------------------------------------- 1 | import { useAsyncWithTimer } from "../../shared/hooks/async.js"; 2 | import { getDisabledDomains } from "../services/disabledDomains.js"; 3 | 4 | const REFRESH_DELAY = 2500; 5 | 6 | export function useDisabledDomains( 7 | deps: React.DependencyList = [] 8 | ): [domains: Array, loading: boolean, error: Error | null] { 9 | const { error, loading, value } = useAsyncWithTimer(getDisabledDomains, REFRESH_DELAY, deps); 10 | return [value || [], loading, error]; 11 | } 12 | -------------------------------------------------------------------------------- /source/popup/styles/popup.sass: -------------------------------------------------------------------------------- 1 | html 2 | margin: 0 3 | height: 100% 4 | 5 | body 6 | width: 350px 7 | height: 100% 8 | padding: 0px 9 | margin: 0 10 | overflow: hidden 11 | 12 | &.in-page 13 | width: 100% 14 | 15 | #root 16 | min-height: unset !important 17 | 18 | h1 19 | font-size: 20px 20 | line-height: 20px 21 | 22 | #root 23 | display: flex 24 | flex-direction: column 25 | min-height: 400px 26 | height: 100% 27 | 28 | @import ../../shared/styles/base 29 | -------------------------------------------------------------------------------- /source/tab/services/logins/disabled.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { sendBackgroundMessage } from "../../../shared/services/messaging.js"; 3 | import { BackgroundMessageType } from "../../types.js"; 4 | 5 | export async function getDisabledDomains(): Promise> { 6 | const resp = await sendBackgroundMessage({ 7 | type: BackgroundMessageType.GetDisabledDomains 8 | }); 9 | if (resp.error) { 10 | throw new Layerr(resp.error, "Failed fetching disabled login domains"); 11 | } 12 | return resp.domains ?? []; 13 | } 14 | -------------------------------------------------------------------------------- /source/popup/queries/loginMemory.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { sendBackgroundMessage } from "../../shared/services/messaging.js"; 3 | import { BackgroundMessageType } from "../types.js"; 4 | 5 | export async function clearSavedLoginPrompt(loginID: string): Promise { 6 | const resp = await sendBackgroundMessage({ 7 | credentialsID: loginID, 8 | type: BackgroundMessageType.ClearSavedCredentialsPrompt 9 | }); 10 | if (resp.error) { 11 | throw new Layerr(resp.error, "Failed clearing saved credentials prompt"); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /source/full/services/notifications.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { sendBackgroundMessage } from "../../shared/services/messaging.js"; 3 | import { BackgroundMessageType } from "../types.js"; 4 | 5 | export async function updateReadNotifications(notificationName: string): Promise { 6 | const resp = await sendBackgroundMessage({ 7 | notification: notificationName, 8 | type: BackgroundMessageType.MarkNotificationRead 9 | }); 10 | if (resp.error) { 11 | throw new Layerr(resp.error, "Failed updating read notifications"); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /source/popup/hooks/tab.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useAsync } from "../../shared/hooks/async.js"; 3 | import { getCurrentTab } from "../../shared/library/extension.js"; 4 | 5 | export function useCurrentTabURL(): [loading: boolean, url: string | null] { 6 | const { loading, value } = useAsync(getCurrentTab, [], { 7 | clearOnExec: false, 8 | updateInterval: 5000 9 | }); 10 | const tabURL = useMemo(() => { 11 | if (!value) return null; 12 | return value.url ?? null; 13 | }, [value]); 14 | return [loading, tabURL]; 15 | } 16 | -------------------------------------------------------------------------------- /source/popup/queries/disabledDomains.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { sendBackgroundMessage } from "../../shared/services/messaging.js"; 3 | import { BackgroundMessageType } from "../types.js"; 4 | 5 | export async function disableDomainForLogin(loginID: string): Promise { 6 | const resp = await sendBackgroundMessage({ 7 | credentialsID: loginID, 8 | type: BackgroundMessageType.DisableSavePromptForCredentials 9 | }); 10 | if (resp.error) { 11 | throw new Layerr(resp.error, "Failed disabling save prompt for login"); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /source/popup/services/recents.ts: -------------------------------------------------------------------------------- 1 | import { SearchResult } from "buttercup"; 2 | import { Layerr } from "layerr"; 3 | import { sendBackgroundMessage } from "../../shared/services/messaging.js"; 4 | import { BackgroundMessageType } from "../types.js"; 5 | 6 | export async function trackEntryRecentUse(item: SearchResult): Promise { 7 | const resp = await sendBackgroundMessage({ 8 | entry: item, 9 | type: BackgroundMessageType.TrackRecentEntry 10 | }); 11 | if (resp.error) { 12 | throw new Layerr(resp.error, "Failed tracking entry use"); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "esModuleInterop": true, 5 | "jsx": "react-jsx", 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "outDir": "./dist", 9 | "resolveJsonModule": true, 10 | "strict": false, 11 | "strictNullChecks": true, 12 | "target": "ES6", 13 | "types": ["chrome", "react", "react-dom"] 14 | }, 15 | "include": [ 16 | "./source/**/*" 17 | ], 18 | "exclude":[ 19 | "dist", 20 | "node_modules" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /source/full/hooks/document.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | const TITLE_SEPARATOR = "⋅"; 4 | 5 | let __originalTitle: string | null = null; 6 | 7 | export function useTitle(title: string) { 8 | useEffect(() => { 9 | if (!__originalTitle) { 10 | __originalTitle = document.title; 11 | } 12 | document.title = `${title} ${TITLE_SEPARATOR} ${__originalTitle}`; 13 | return () => { 14 | if (__originalTitle) { 15 | document.title = __originalTitle; 16 | __originalTitle = null; 17 | } 18 | }; 19 | }, []); 20 | } 21 | -------------------------------------------------------------------------------- /source/tab/library/styles.ts: -------------------------------------------------------------------------------- 1 | export const CLEAR_STYLES = { 2 | margin: "0px", 3 | minWidth: "0px", 4 | minHeight: "0px", 5 | padding: "0px" 6 | }; 7 | 8 | export function findBestZIndexInContainer(parentElement: HTMLElement) { 9 | let highest = 0; 10 | [...parentElement.children].forEach((child) => { 11 | const { zIndex } = window.getComputedStyle(child); 12 | if (zIndex) { 13 | const num = parseInt(zIndex, 10); 14 | if (!isNaN(num) && num > highest) { 15 | highest = num; 16 | } 17 | } 18 | }); 19 | return highest + 1; 20 | } 21 | -------------------------------------------------------------------------------- /source/full/services/vaults.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { sendBackgroundMessage } from "../../shared/services/messaging.js"; 3 | import { BackgroundMessageType, VaultsTree } from "../types.js"; 4 | 5 | export async function getVaultsTree(): Promise { 6 | const resp = await sendBackgroundMessage({ 7 | type: BackgroundMessageType.GetDesktopVaultsTree 8 | }); 9 | if (resp.error) { 10 | throw new Layerr(resp.error, "Failed fetching vaults tree"); 11 | } 12 | if (!resp.vaultsTree) { 13 | throw new Error("No vaults tree returned"); 14 | } 15 | return resp.vaultsTree; 16 | } 17 | -------------------------------------------------------------------------------- /source/tab/services/config.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { sendBackgroundMessage } from "../../shared/services/messaging.js"; 3 | import { BackgroundMessageType, Configuration } from "../types.js"; 4 | 5 | export async function getConfig(): Promise { 6 | const resp = await sendBackgroundMessage({ 7 | type: BackgroundMessageType.GetConfiguration 8 | }); 9 | if (resp.error) { 10 | throw new Layerr(resp.error, "Failed fetching configuration"); 11 | } 12 | if (!resp.config) { 13 | throw new Error("No config returned from background"); 14 | } 15 | return resp.config; 16 | } 17 | -------------------------------------------------------------------------------- /source/background/services/desktop/header.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { LocalStorageItem } from "../../types.js"; 3 | import { getLocalValue } from "../storage.js"; 4 | 5 | export async function generateAuthHeader(): Promise { 6 | const clientID = await getLocalValue(LocalStorageItem.APIClientID); 7 | if (!clientID) { 8 | throw new Layerr( 9 | { 10 | info: { 11 | i18n: "error.code.desktop-connection-not-authorised" 12 | } 13 | }, 14 | "No API client ID set" 15 | ); 16 | } 17 | return `Client ${clientID}`; 18 | } 19 | -------------------------------------------------------------------------------- /source/popup/services/entry.ts: -------------------------------------------------------------------------------- 1 | import { SearchResult } from "buttercup"; 2 | import { Layerr } from "layerr"; 3 | import { sendBackgroundMessage } from "../../shared/services/messaging.js"; 4 | import { BackgroundMessageType } from "../types.js"; 5 | 6 | export async function openPageForEntry(item: SearchResult, autoLogin: boolean): Promise { 7 | const resp = await sendBackgroundMessage({ 8 | autoLogin, 9 | entry: item, 10 | type: BackgroundMessageType.OpenEntryPage 11 | }); 12 | if (resp.error) { 13 | throw new Layerr(resp.error, "Failed opening page"); 14 | } 15 | return resp.opened ?? false; 16 | } 17 | -------------------------------------------------------------------------------- /source/shared/components/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ThemeProvider as StyledThemeProvider } from "styled-components"; 3 | import themesInternal from "../themes.js"; 4 | import { ChildElements } from "../types.js"; 5 | 6 | interface ThemeProviderProps { 7 | children: ChildElements; 8 | darkMode: boolean; 9 | } 10 | 11 | export function ThemeProvider(props: ThemeProviderProps) { 12 | const { children, darkMode } = props; 13 | return ( 14 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /source/tab/library/page.ts: -------------------------------------------------------------------------------- 1 | import { extractDomain } from "../../shared/library/domain.js"; 2 | 3 | export function currentDomainDisabled( 4 | disabledDomains: Array, 5 | currentDomain: string = getCurrentDomain() 6 | ): boolean { 7 | return disabledDomains.some((disabledDomain) => { 8 | const idx = currentDomain.indexOf(disabledDomain); 9 | return idx === currentDomain.length - disabledDomain.length; 10 | }); 11 | } 12 | 13 | export function getCurrentDomain(): string { 14 | return extractDomain(getCurrentURL()); 15 | } 16 | 17 | export function getCurrentTitle(): string { 18 | return document.title; 19 | } 20 | 21 | export function getCurrentURL(): string { 22 | return window.location.href; 23 | } 24 | -------------------------------------------------------------------------------- /source/shared/notifications/pages/WelcomeV3.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | import { t } from "../../i18n/trans.js"; 3 | 4 | export const TITLE = "notifications.page.welcome-v3.title"; 5 | 6 | export function Page() { 7 | return ( 8 | 9 |

10 |

11 |

12 |

13 |

- The Buttercup Team

14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /source/tab/library/position.ts: -------------------------------------------------------------------------------- 1 | import { ElementRect } from "../types.js"; 2 | 3 | export function getElementRectInDocument(el: HTMLElement): ElementRect { 4 | const boundingRect = el.getBoundingClientRect(); 5 | return { 6 | x: boundingRect.left + document.documentElement.scrollLeft, 7 | y: boundingRect.top + document.documentElement.scrollTop, 8 | width: boundingRect.width, 9 | height: boundingRect.height 10 | }; 11 | } 12 | 13 | export function recalculateRectForIframe(rect: ElementRect, iframe: HTMLIFrameElement): ElementRect { 14 | const framePos = getElementRectInDocument(iframe); 15 | return { 16 | ...rect, 17 | x: framePos.x + rect.x, 18 | y: framePos.y + rect.y 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /source/shared/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Callout, Intent } from "@blueprintjs/core"; 3 | import styled from "styled-components"; 4 | 5 | interface ErrorMessageProps { 6 | message: string; 7 | scroll?: boolean; 8 | } 9 | 10 | const ErrorCallout = styled(Callout)` 11 | margin: 4px; 12 | box-sizing: border-box; 13 | width: calc(100% - 8px) !important; 14 | height: calc(100% - 8px) !important; 15 | overflow: ${p => p.scroll ? "scroll" : "hidden"}; 16 | `; 17 | 18 | export function ErrorMessage(props: ErrorMessageProps) { 19 | const { 20 | message, 21 | scroll = true 22 | } = props; 23 | return ( 24 | 25 | {message} 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /source/background/services/tabs.ts: -------------------------------------------------------------------------------- 1 | import { getExtensionAPI } from "../../shared/extension.js"; 2 | import { TabEvent } from "../types.js"; 3 | 4 | export async function sendTabsMessage(payload: TabEvent, tabIDs: Array | null = null): Promise { 5 | const browser = getExtensionAPI(); 6 | const targetTabIDs = Array.isArray(tabIDs) 7 | ? tabIDs 8 | : ( 9 | await browser.tabs.query({ 10 | status: "complete" 11 | }) 12 | ).reduce((output: Array, tab) => { 13 | if (!tab.id) return output; 14 | return [...output, tab.id]; 15 | }, []); 16 | await Promise.all( 17 | targetTabIDs.map(async (tabID) => { 18 | browser.tabs.sendMessage(tabID, payload); 19 | }) 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /scripts/version.js: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | import { resolve } from "path"; 3 | import { fileURLToPath } from "url"; 4 | 5 | const __dirname = fileURLToPath(new URL(".", import.meta.url)); 6 | 7 | const packageInfo = JSON.parse(readFileSync( 8 | resolve(__dirname, "../package.json"), 9 | "utf8" 10 | )); 11 | 12 | const buildDate = new Date(); 13 | const built = `${buildDate.getUTCFullYear()}-${String(buildDate.getUTCMonth() + 1).padStart(2, "0")}-${String(buildDate.getUTCDate()).padStart(2, "0")}`; 14 | 15 | const output = `// Do not edit this file - it is generated automatically at build time 16 | 17 | export const BUILD_DATE = "${built}"; 18 | export const VERSION = "${packageInfo.version}"; 19 | `; 20 | 21 | writeFileSync( 22 | resolve(__dirname, "../source/shared/library/version.ts"), 23 | output 24 | ); 25 | -------------------------------------------------------------------------------- /source/shared/styles/base.sass: -------------------------------------------------------------------------------- 1 | @import "~@blueprintjs/core/lib/css/blueprint.css" 2 | @import "~@blueprintjs/icons/lib/css/blueprint-icons.css" 3 | @import "~@blueprintjs/popover2/lib/css/blueprint-popover2.css" 4 | @import "~@blueprintjs/select/lib/css/blueprint-select.css" 5 | 6 | @import "fonts" 7 | 8 | $bc-brand-colour: #00B7AC 9 | $bc-brand-colour-dark: #179E94 10 | 11 | html, body, textarea, select, input, button, div 12 | font-family: "OpenSans" 13 | 14 | body 15 | font-size: 14px 16 | 17 | &.bp4-dark 18 | background-color: #383E47 // Dark Grey 4 19 | 20 | .bp4-input:focus, .bp4-input.bp4-active 21 | box-shadow: inset 0 0 0 1px $bc-brand-colour, 0 0 0 2px rgba(45, 114, 210, 0.3), inset 0 1px 1px rgba(17, 20, 24, 0.2) 22 | 23 | a 24 | color: $bc-brand-colour-dark 25 | 26 | &:hover 27 | color: $bc-brand-colour 28 | -------------------------------------------------------------------------------- /source/shared/library/buffer.ts: -------------------------------------------------------------------------------- 1 | export function arrayBufferToHex(buffer: ArrayBuffer): string { 2 | return [...new Uint8Array(buffer)].map((x) => x.toString(16).padStart(2, "0")).join(""); 3 | } 4 | 5 | export function arrayBufferToString(buffer: ArrayBuffer): string { 6 | return String.fromCharCode.apply(null, new Uint8Array(buffer)); 7 | } 8 | 9 | export function base64DecodeUnicode(str: string): string { 10 | return window.atob(str); 11 | } 12 | 13 | export function base64EncodeUnicode(str: string): string { 14 | return window.btoa(str); 15 | } 16 | 17 | export function stringToArrayBuffer(str: string): ArrayBuffer { 18 | const buffer = new ArrayBuffer(str.length); 19 | const bufferView = new Uint8Array(buffer); 20 | for (let i = 0; i < str.length; i += 1) { 21 | bufferView[i] = str.charCodeAt(i); 22 | } 23 | return buffer; 24 | } 25 | -------------------------------------------------------------------------------- /source/shared/library/error.ts: -------------------------------------------------------------------------------- 1 | import { isError, Layerr } from "layerr"; 2 | import { t } from "../i18n/trans.js"; 3 | 4 | export function errorToString(error: Error | Layerr): string { 5 | return localisedErrorMessage(error); 6 | } 7 | 8 | export function localisedErrorMessage(error: Error | Layerr): string { 9 | if (!isError(error)) { 10 | return `${error}`; 11 | } 12 | const { i18n } = Layerr.info(error); 13 | if (i18n) { 14 | const translated = t(i18n); 15 | if (translated) return translated; 16 | } 17 | return error.message; 18 | } 19 | 20 | export function stringToError(error: Error | Layerr | string): Layerr | Error { 21 | if (isError(error as Error)) return error as Error; 22 | const isI18N = /^[a-z0-9_-]+(\.[a-z0-9_-]+){1,}$/i.test(error as string); 23 | return isI18N ? new Error(t(error as string)) : new Error(error as string); 24 | } 25 | -------------------------------------------------------------------------------- /source/full/services/disabledDomains.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { sendBackgroundMessage } from "../../shared/services/messaging.js"; 3 | import { BackgroundMessageType } from "../types.js"; 4 | 5 | export async function getDisabledDomains(): Promise> { 6 | const resp = await sendBackgroundMessage({ 7 | type: BackgroundMessageType.GetDisabledDomains 8 | }); 9 | if (resp.error) { 10 | throw new Layerr(resp.error, "Failed fetching disabled domains"); 11 | } 12 | return resp.domains ?? []; 13 | } 14 | 15 | export async function removeDisabledDomain(domain: string): Promise { 16 | const resp = await sendBackgroundMessage({ 17 | domains: [domain], 18 | type: BackgroundMessageType.DeleteDisabledDomains 19 | }); 20 | if (resp.error) { 21 | throw new Layerr(resp.error, "Failed removing disabled domains"); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /source/background/services/disabledDomains.ts: -------------------------------------------------------------------------------- 1 | import { getSyncValue, setSyncValue } from "./storage.js"; 2 | import { SyncStorageItem } from "../types.js"; 3 | 4 | export async function disableLoginsOnDomain(domain: string): Promise { 5 | const currentDomains = new Set(await getDisabledDomains()); 6 | currentDomains.add(domain); 7 | await setSyncValue(SyncStorageItem.DisabledDomains, JSON.stringify([...currentDomains])); 8 | } 9 | 10 | export async function getDisabledDomains(): Promise> { 11 | const currentDomainsRaw = await getSyncValue(SyncStorageItem.DisabledDomains); 12 | return currentDomainsRaw ? JSON.parse(currentDomainsRaw) : []; 13 | } 14 | 15 | export async function removeDisabledFlagForDomain(domain: string): Promise { 16 | const currentDomains = new Set(await getDisabledDomains()); 17 | currentDomains.delete(domain); 18 | await setSyncValue(SyncStorageItem.DisabledDomains, JSON.stringify([...currentDomains])); 19 | } 20 | -------------------------------------------------------------------------------- /source/popup/services/clipboard.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | 3 | export async function copyTextToClipboard(text: string): Promise { 4 | // Navigator clipboard api needs a secure context (https) 5 | if (navigator.clipboard && window.isSecureContext) { 6 | await navigator.clipboard.writeText(text); 7 | } else { 8 | // Use the 'out of viewport hidden text area' trick 9 | const textArea = document.createElement("textarea"); 10 | textArea.value = text; 11 | // Move textarea out of the viewport so it's not visible 12 | textArea.style.position = "absolute"; 13 | textArea.style.left = "-999999px"; 14 | document.body.prepend(textArea); 15 | textArea.select(); 16 | try { 17 | document.execCommand("copy"); 18 | } catch (error) { 19 | throw new Layerr(error, "Failed copying text"); 20 | } finally { 21 | textArea.remove(); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /source/tab/library/dismount.ts: -------------------------------------------------------------------------------- 1 | export function onElementDismount(el: HTMLElement, callback: () => void): void { 2 | let active = true, 3 | timer: ReturnType; 4 | const disconnect = () => { 5 | active = false; 6 | mutObs.disconnect(); 7 | clearTimeout(timer); 8 | }; 9 | const mutObs = new MutationObserver((records) => { 10 | if (!active) return; 11 | const wasRemoved = records.some((record) => { 12 | return [...record.removedNodes].includes(el); 13 | }); 14 | if (wasRemoved) { 15 | disconnect(); 16 | callback(); 17 | } 18 | }); 19 | if (!el.parentElement) { 20 | throw new Error("No parent element found for target"); 21 | } 22 | mutObs.observe(el.parentElement, { childList: true }); 23 | timer = setTimeout(() => { 24 | if (!el.parentElement) { 25 | disconnect(); 26 | callback(); 27 | } 28 | }, 50); 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: push 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [20.x] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Node.js specs ${{ matrix.node-version }} 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | - run: npm ci 18 | - run: npm run test:format 19 | release: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | node-version: [20.x] 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Node.js specs ${{ matrix.node-version }} 27 | uses: actions/setup-node@v1 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | - run: npm ci 31 | - run: npm run release 32 | -------------------------------------------------------------------------------- /source/popup/components/vaults/VaultStateIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { VaultSourceStatus } from "buttercup"; 3 | import { Colors, Icon, IconName } from "@blueprintjs/core"; 4 | import styled from "styled-components"; 5 | 6 | interface VaultStateIndicatorProps { 7 | state: VaultSourceStatus; 8 | } 9 | 10 | // const StateIcon = styled(Icon)` 11 | // fill: ${Colors.GREEN3}; 12 | // `; 13 | 14 | export function VaultStateIndicator(props: VaultStateIndicatorProps) { 15 | const [colour, icon] = useMemo<[string, IconName]>(() => { 16 | switch (props.state) { 17 | case VaultSourceStatus.Unlocked: 18 | return [Colors.GREEN4, "unlock"]; 19 | case VaultSourceStatus.Locked: 20 | return [Colors.RED4, "lock"]; 21 | default: 22 | return [Colors.ORANGE4, "exchange"]; 23 | } 24 | }, [props.state]); 25 | return ( 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /source/popup/components/contexts/LaunchContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, createContext } from "react"; 2 | 3 | interface LaunchContextProps { 4 | children: ReactNode; 5 | formID?: string | null; 6 | loginID?: string | null; 7 | source: "popup" | "page"; 8 | url?: string | null; 9 | } 10 | 11 | interface LaunchContextDefaultValue { 12 | formID: string | null; 13 | loginID: string | null; 14 | source: "popup" | "page"; 15 | url: string | null; 16 | } 17 | 18 | export const LaunchContext = createContext({} as LaunchContextDefaultValue); 19 | LaunchContext.displayName = "LaunchContext"; 20 | 21 | export function LaunchContextProvider(props: LaunchContextProps) { 22 | return ( 23 | 29 | {props.children} 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /source/shared/services/messaging.ts: -------------------------------------------------------------------------------- 1 | import { getExtensionAPI } from "../extension.js"; 2 | import { stringToError } from "../library/error.js"; 3 | import { MESSAGE_DEFAULT_TIMEOUT } from "../symbols.js"; 4 | import { BackgroundMessage, BackgroundResponse, TabEvent } from "../../popup/types.js"; 5 | 6 | export async function sendBackgroundMessage( 7 | msg: BackgroundMessage, 8 | timeout: number = MESSAGE_DEFAULT_TIMEOUT 9 | ): Promise { 10 | const browser = getExtensionAPI(); 11 | return new Promise((resolve, reject) => { 12 | const timer = setTimeout(() => { 13 | reject(new Error(`Timed out waiting for response to message: ${msg.type} (${timeout} ms)`)); 14 | }, timeout); 15 | browser.runtime.sendMessage(msg, (resp) => { 16 | clearTimeout(timer); 17 | if (resp.error) { 18 | reject(stringToError(resp.error)); 19 | return; 20 | } 21 | resolve(resp as BackgroundResponse); 22 | }); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /source/shared/library/otp.ts: -------------------------------------------------------------------------------- 1 | import { SearchResult } from "buttercup"; 2 | import { Layerr } from "layerr"; 3 | import * as OTPAuth from "otpauth"; 4 | 5 | function extractFirstOTPURI(entry: SearchResult): string | null { 6 | let key: string | null = null, 7 | value: string | null = null; 8 | for (const prop in entry.properties) { 9 | if (!/^otpauth:\/\//.test(entry.properties[prop])) continue; 10 | if (!key || prop.length < key.length) { 11 | key = prop; 12 | value = entry.properties[prop]; 13 | } 14 | } 15 | return value ?? null; 16 | } 17 | 18 | export function otpURIToDigits(uri: string): string { 19 | try { 20 | const otp = OTPAuth.URI.parse(uri); 21 | return otp.generate(); 22 | } catch (err) { 23 | throw new Layerr(err, "Failed generating OTP code for URI"); 24 | } 25 | } 26 | 27 | export function searchResultToOTP(entry: SearchResult): string | null { 28 | const uri = extractFirstOTPURI(entry); 29 | if (!uri) return null; 30 | return otpURIToDigits(uri); 31 | } 32 | -------------------------------------------------------------------------------- /source/background/services/autoLogin.ts: -------------------------------------------------------------------------------- 1 | import { SearchResult } from "buttercup"; 2 | import ExpiryMap from "expiry-map"; 3 | 4 | interface RegisteredItem { 5 | entry: SearchResult; 6 | tabID: number; 7 | } 8 | 9 | const REGISTER_MAX_AGE = 30 * 1000; // 30 seconds 10 | 11 | let __register: ExpiryMap | null = null; 12 | 13 | export function getAutoLoginForTab(tabID: number): SearchResult | null { 14 | const register = getRegister(); 15 | const key = `tab-${tabID}`; 16 | if (register.has(key)) { 17 | const item = (register.get(key) as RegisteredItem).entry; 18 | register.delete(key); 19 | return item; 20 | } 21 | return null; 22 | } 23 | 24 | function getRegister(): ExpiryMap { 25 | if (!__register) { 26 | __register = new ExpiryMap(REGISTER_MAX_AGE); 27 | } 28 | return __register; 29 | } 30 | 31 | export function registerAutoLogin(entry: SearchResult, tabID: number): void { 32 | const register = getRegister(); 33 | register.set(`tab-${tabID}`, { entry, tabID }); 34 | } 35 | -------------------------------------------------------------------------------- /source/shared/library/clone.ts: -------------------------------------------------------------------------------- 1 | export function naiveClone | Record>(item: T): T { 2 | if (Array.isArray(item)) { 3 | return naiveCloneArray(item); 4 | } 5 | return naiveCloneObject(item); 6 | } 7 | 8 | function naiveCloneArray>(arr: T): T { 9 | const clone = [...arr] as T; 10 | for (let i = 0; i < clone.length; i += 1) { 11 | if (Array.isArray(clone[i])) { 12 | clone[i] = naiveCloneArray(clone[i]); 13 | } else if (clone[i] && typeof clone[i] === "object") { 14 | clone[i] = naiveCloneObject(clone[i]); 15 | } 16 | } 17 | return clone; 18 | } 19 | 20 | function naiveCloneObject>(obj: T): T { 21 | const clone = { ...obj }; 22 | for (const key in clone) { 23 | if (Array.isArray(clone[key])) { 24 | clone[key] = naiveCloneArray(clone[key]); 25 | } else if (typeof clone[key] === "object" && clone[key]) { 26 | clone[key] = naiveCloneObject(clone[key]); 27 | } 28 | } 29 | return clone; 30 | } 31 | -------------------------------------------------------------------------------- /source/shared/library/vaultTypes.ts: -------------------------------------------------------------------------------- 1 | import { VaultType } from "../types.js"; 2 | import VAULT_TYPE_IMAGE_DROPBOX from "../../../resources/providers/dropbox-256.png"; 3 | import VAULT_TYPE_IMAGE_FILE from "../../../resources/providers/file-256.png"; 4 | import VAULT_TYPE_IMAGE_GOOGLEDRIVE from "../../../resources/providers/googledrive-256.png"; 5 | import VAULT_TYPE_IMAGE_WEBDAV from "../../../resources/providers/webdav-256.png"; 6 | 7 | interface VaultTypeDescription { 8 | image: any; 9 | invertOnDarkMode: boolean; 10 | } 11 | 12 | export const VAULT_TYPES: Record = { 13 | [VaultType.Dropbox]: { 14 | image: VAULT_TYPE_IMAGE_DROPBOX, 15 | invertOnDarkMode: false 16 | }, 17 | [VaultType.File]: { 18 | image: VAULT_TYPE_IMAGE_FILE, 19 | invertOnDarkMode: false 20 | }, 21 | [VaultType.GoogleDrive]: { 22 | image: VAULT_TYPE_IMAGE_GOOGLEDRIVE, 23 | invertOnDarkMode: false 24 | }, 25 | [VaultType.WebDAV]: { 26 | image: VAULT_TYPE_IMAGE_WEBDAV, 27 | invertOnDarkMode: true 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /source/background/services/crypto.ts: -------------------------------------------------------------------------------- 1 | import { EncryptionAlgorithm, createAdapter } from "iocane"; 2 | import { deriveSecretKey, importECDHKey } from "./cryptoKeys.js"; 3 | 4 | export async function decryptPayload( 5 | payload: string, 6 | sourcePublicKey: string, 7 | targetPrivateKey: string 8 | ): Promise { 9 | const privateKey = await importECDHKey(targetPrivateKey); 10 | const publicKey = await importECDHKey(sourcePublicKey); 11 | const secret = await deriveSecretKey(privateKey, publicKey); 12 | return createAdapter().decrypt(payload, secret) as Promise; 13 | } 14 | 15 | export async function encryptPayload( 16 | payload: string, 17 | sourcePrivateKey: string, 18 | targetPublicKey: string 19 | ): Promise { 20 | const privateKey = await importECDHKey(sourcePrivateKey); 21 | const publicKey = await importECDHKey(targetPublicKey); 22 | const secret = await deriveSecretKey(privateKey, publicKey); 23 | return createAdapter() 24 | .setAlgorithm(EncryptionAlgorithm.GCM) 25 | .setDerivationRounds(100000) 26 | .encrypt(payload, secret) as Promise; 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Perry Mitchell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /source/shared/hooks/theme.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useConfig } from "./config.js"; 3 | import { Classes } from "@blueprintjs/core"; 4 | 5 | let __bodyThemeAttached: boolean = false; 6 | 7 | export function useBodyThemeClass(theme: "dark" | "light"): void { 8 | useEffect(() => { 9 | if (__bodyThemeAttached) { 10 | console.warn("Multiple body theme controllers running"); 11 | return; 12 | } 13 | __bodyThemeAttached = true; 14 | if (theme === "dark") { 15 | document.body.classList.add(Classes.DARK); 16 | } else { 17 | document.body.classList.remove(Classes.DARK); 18 | } 19 | return () => { 20 | __bodyThemeAttached = false; 21 | }; 22 | }, [theme]); 23 | } 24 | 25 | export function useTheme(): "dark" | "light" { 26 | const [config] = useConfig(); 27 | if (!config || config.useSystemTheme) { 28 | const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)"); 29 | return darkThemeMq.matches ? "dark" : "light"; 30 | } 31 | return config?.theme === "dark" ? "dark" : "light"; 32 | } 33 | -------------------------------------------------------------------------------- /source/tab/library/resize.ts: -------------------------------------------------------------------------------- 1 | export function onBodyResize( 2 | callback: (newWidth: number, newHeight: number, lastWidth: number, lastHeight: number) => void 3 | ): () => void { 4 | let lastWidth = 0, 5 | lastHeight = 0; 6 | const watch = setInterval(() => { 7 | const newWidth = document.body.offsetWidth; 8 | const newHeight = document.body.offsetHeight; 9 | if (newWidth !== lastWidth || newHeight !== lastHeight) { 10 | callback(newWidth, newHeight, lastWidth, lastHeight); 11 | lastWidth = newWidth; 12 | lastHeight = newHeight; 13 | } 14 | }, 200); 15 | return () => { 16 | clearInterval(watch); 17 | }; 18 | } 19 | 20 | export function onBodyWidthResize(callback: (newWidth: number, lastWidth: number) => void): () => void { 21 | let lastWidth = 0; 22 | const watch = setInterval(() => { 23 | const newWidth = document.body.offsetWidth; 24 | if (newWidth !== lastWidth) { 25 | callback(newWidth, lastWidth); 26 | lastWidth = newWidth; 27 | } 28 | }, 200); 29 | return () => { 30 | clearInterval(watch); 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /source/shared/queries/config.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { sendBackgroundMessage } from "../services/messaging.js"; 3 | import { BackgroundMessageType, Configuration } from "../../popup/types.js"; 4 | 5 | export async function getConfig(): Promise { 6 | const resp = await sendBackgroundMessage({ 7 | type: BackgroundMessageType.GetConfiguration 8 | }); 9 | if (resp.error) { 10 | throw new Layerr(resp.error, "Failed fetching application configuration"); 11 | } 12 | if (!resp.config) { 13 | throw new Error("No config returned from background"); 14 | } 15 | return resp.config; 16 | } 17 | 18 | export async function setConfigValue( 19 | key: T, 20 | value: Configuration[T] 21 | ): Promise { 22 | const resp = await sendBackgroundMessage({ 23 | configKey: key, 24 | configValue: value, 25 | type: BackgroundMessageType.SetConfigurationValue 26 | }); 27 | if (resp.error) { 28 | throw new Layerr(resp.error, "Failed fetching application configuration"); 29 | } 30 | if (!resp.config) { 31 | throw new Error("No config returned from background"); 32 | } 33 | return resp.config; 34 | } 35 | -------------------------------------------------------------------------------- /source/shared/components/loading/BusyLoader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Classes, Overlay, H4, Spinner, Text } from "@blueprintjs/core"; 3 | import styled from "styled-components"; 4 | import cn from "classnames"; 5 | 6 | interface BusyLoaderProps { 7 | description: string; 8 | title: string; 9 | } 10 | 11 | const OverlayBody = styled.div` 12 | display: flex; 13 | flex-direction: column; 14 | justify-content: flex-start; 15 | align-items: center; 16 | `; 17 | const OverlayContainer = styled(Overlay)` 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | `; 22 | 23 | export function BusyLoader(props: BusyLoaderProps) { 24 | return ( 25 | 32 | 33 | 34 |
35 |

{props.title}

36 | {props.description} 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /source/shared/hooks/config.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import { useAsync } from "./async.js"; 3 | import { getConfig } from "../queries/config.js"; 4 | import { setConfigValue as setNewBackgroundValue } from "../queries/config.js"; 5 | import { Configuration } from "../types.js"; 6 | import { useGlobal } from "./global.js"; 7 | 8 | export function useConfig(): [ 9 | Configuration | null, 10 | Error | null, 11 | (setKey: T, value: Configuration[T]) => void 12 | ] { 13 | const [ts, setTs] = useGlobal("configFlagTs"); 14 | const { value, error } = useAsync(getConfig, [ts], { 15 | clearOnExec: false 16 | }); 17 | const [changeError, setChangeError] = useState(null); 18 | const setConfigValue = useCallback((setKey: T, value: Configuration[T]) => { 19 | setChangeError(null); 20 | setNewBackgroundValue(setKey, value) 21 | .then(() => { 22 | setTs(Date.now()); 23 | }) 24 | .catch((err) => { 25 | console.error(err); 26 | setChangeError(err); 27 | }); 28 | }, []); 29 | return [value || null, changeError || error, setConfigValue]; 30 | } 31 | -------------------------------------------------------------------------------- /source/shared/i18n/trans.ts: -------------------------------------------------------------------------------- 1 | import i18next, { TOptions } from "i18next"; 2 | 3 | import en from "./translations/en.json"; 4 | import nl from "./translations/nl.json"; 5 | 6 | export const DEFAULT_LANGUAGE = "en"; 7 | export const TRANSLATIONS = { 8 | en, // Keep as first item 9 | // All others sorted alphabetically: 10 | nl 11 | }; 12 | 13 | export async function changeLanguage(lang: string) { 14 | await i18next.changeLanguage(lang); 15 | } 16 | 17 | export async function initialise(lang: string) { 18 | await i18next.init({ 19 | lng: lang, 20 | fallbackLng: DEFAULT_LANGUAGE, 21 | debug: false, 22 | resources: Object.keys(TRANSLATIONS).reduce( 23 | (output, lang) => ({ 24 | ...output, 25 | [lang]: { 26 | translation: TRANSLATIONS[lang] 27 | } 28 | }), 29 | {} 30 | ) 31 | }); 32 | } 33 | 34 | export function onLanguageChanged(callback: (lang: string) => void): () => void { 35 | const cb = (lang: string) => callback(lang); 36 | i18next.on("languageChanged", cb); 37 | return () => { 38 | i18next.off("languageChanged", cb); 39 | }; 40 | } 41 | 42 | export function t(key: string, options?: TOptions) { 43 | return i18next.t(key, options); 44 | } 45 | -------------------------------------------------------------------------------- /source/tab/services/autoLogin.ts: -------------------------------------------------------------------------------- 1 | import { LoginTarget } from "@buttercup/locust"; 2 | import { SearchResult } from "buttercup"; 3 | import { Layerr } from "layerr"; 4 | import { sendBackgroundMessage } from "../../shared/services/messaging.js"; 5 | import { BackgroundMessageType } from "../types.js"; 6 | import { searchResultToOTP } from "../../shared/library/otp.js"; 7 | 8 | async function getAutoLogin(): Promise { 9 | const resp = await sendBackgroundMessage({ 10 | type: BackgroundMessageType.GetAutoLoginForTab 11 | }); 12 | if (resp.error) { 13 | throw new Layerr(resp.error, "Failed fetching auto-login data"); 14 | } 15 | return resp.autoLogin ?? null; 16 | } 17 | 18 | export async function processTargetAutoLogin(loginTarget: LoginTarget): Promise { 19 | const entry = await getAutoLogin(); 20 | if (!entry) return; 21 | if (entry.properties.username) { 22 | loginTarget.fillUsername(entry.properties.username); 23 | } 24 | if (entry.properties.password) { 25 | loginTarget.fillPassword(entry.properties.password); 26 | } 27 | if (loginTarget.otpField) { 28 | const otpDigits = searchResultToOTP(entry); 29 | if (otpDigits) { 30 | loginTarget.fillOTP(otpDigits); 31 | } 32 | } 33 | loginTarget.submit(); 34 | } 35 | -------------------------------------------------------------------------------- /source/full/components/pages/AttributionsPage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { Layout } from "../Layout.js"; 4 | import { t } from "../../../shared/i18n/trans.js"; 5 | import { useTitle } from "../../hooks/document.js"; 6 | import COMPUTER_ICON from "../../../../resources/providers/local-256.png"; 7 | 8 | const AttributionLI = styled.li` 9 | display: flex; 10 | flex-direction: row; 11 | align-items: center; 12 | `; 13 | const ImageIcon = styled.img` 14 | width: auto; 15 | height: 32px; 16 | margin-right: 12px; 17 | `; 18 | 19 | export function AttributionsPage() { 20 | useTitle(t("attributions-page.title")); 21 | return ( 22 | 23 |

Buttercup is Open Source Software and makes use of many free and openly available libraries and resources.

24 |

Below are a list of resource attributions that this browser extension makes use of.

25 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /source/shared/library/domain.ts: -------------------------------------------------------------------------------- 1 | import { EntryURLType, PropertyKeyValueObject, getEntryURLs } from "buttercup"; 2 | import { ParseResultListed, ParseResultType, parseDomain } from "parse-domain"; 3 | 4 | export function domainsReferToSameParent(domain1: string, domain2: string): boolean { 5 | if (domain1 === domain2) return true; 6 | const res1 = parseDomain(domain1); 7 | const res2 = parseDomain(domain2); 8 | if (res1.type !== res2.type) return false; 9 | if (res1.type !== ParseResultType.Listed) return false; 10 | const r1 = (res1 as ParseResultListed).icann; 11 | const r2 = (res2 as ParseResultListed).icann; 12 | if (r1.topLevelDomains.join(".") !== r2.topLevelDomains.join(".")) return false; 13 | return r1.domain === r2.domain; 14 | } 15 | 16 | export function extractDomain(str: string): string { 17 | const domainMatch = str.match(/^https?:\/\/([^\/]+)/i); 18 | if (!domainMatch) return str; 19 | const [, domainPortion] = domainMatch; 20 | const [domain] = domainPortion.split(":"); 21 | return domain; 22 | } 23 | 24 | export function extractEntryDomain(entryProperties: PropertyKeyValueObject): string | null { 25 | const [url] = [ 26 | ...getEntryURLs(entryProperties, EntryURLType.Icon), 27 | ...getEntryURLs(entryProperties, EntryURLType.Any) 28 | ]; 29 | return url ? extractDomain(url) : null; 30 | } 31 | -------------------------------------------------------------------------------- /source/tab/services/logins/saving.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { sendBackgroundMessage } from "../../../shared/services/messaging.js"; 3 | import { BackgroundMessageType, UsedCredentials } from "../../types.js"; 4 | 5 | export async function getCredentialsForID(id: string, excludeSaved: boolean = false): Promise { 6 | const resp = await sendBackgroundMessage({ 7 | credentialsID: id, 8 | excludeSaved, 9 | type: BackgroundMessageType.GetSavedCredentialsForID 10 | }); 11 | if (resp.error) { 12 | throw new Layerr(resp.error, "Failed fetching saved credentials"); 13 | } 14 | return resp.credentials?.[0] ?? null; 15 | } 16 | 17 | export async function getLastSavedCredentials(excludeSaved: boolean = false): Promise { 18 | const resp = await sendBackgroundMessage({ 19 | excludeSaved, 20 | type: BackgroundMessageType.GetLastSavedCredentials 21 | }); 22 | if (resp.error) { 23 | throw new Layerr(resp.error, "Failed fetching last saved credentials"); 24 | } 25 | return resp.credentials?.[0] ?? null; 26 | } 27 | 28 | export function transferLoginCredentials(details: UsedCredentials) { 29 | sendBackgroundMessage({ type: BackgroundMessageType.SaveUsedCredentials, credentials: details }).catch((err) => { 30 | console.error(err); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /source/shared/hooks/global.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "eventemitter3"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | 4 | interface Globals { 5 | configFlagTs: number | null; 6 | } 7 | 8 | const __globals: Globals = { 9 | configFlagTs: null 10 | }; 11 | let __ee: EventEmitter | null = null; 12 | 13 | export function useGlobal(key: K): [Globals[K], (value: Globals[K]) => void] { 14 | useEffect(() => { 15 | if (!__ee) { 16 | __ee = new EventEmitter(); 17 | } 18 | }, []); 19 | const handleEventUpdate = useCallback(() => { 20 | setCurrentValue(__globals[key]); 21 | }, [key]); 22 | useEffect(() => { 23 | if (!__ee) return; 24 | handleEventUpdate(); 25 | __ee.on("update", handleEventUpdate); 26 | return () => { 27 | if (!__ee) return; 28 | __ee.off("update", handleEventUpdate); 29 | }; 30 | }, [handleEventUpdate]); 31 | const [currentValue, setCurrentValue] = useState(__globals[key]); 32 | const handleValueChange = useCallback( 33 | (value: Globals[K]) => { 34 | __globals[key] = value; 35 | setCurrentValue(value); 36 | if (__ee) { 37 | __ee.emit("update"); 38 | } 39 | }, 40 | [key] 41 | ); 42 | return [currentValue, handleValueChange]; 43 | } 44 | -------------------------------------------------------------------------------- /resources/manifest.v2.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | 4 | "name": "Buttercup", 5 | "description": "Browser extension for Buttercup, the secure and easy-to-use password manager.", 6 | "version": "0.0.0", 7 | 8 | "icons": { 9 | "256": "manifest-res/buttercup-256.png", 10 | "128": "manifest-res/buttercup-128.png", 11 | "48": "manifest-res/buttercup-48.png", 12 | "16": "manifest-res/buttercup-16.png" 13 | }, 14 | 15 | "background": { 16 | "scripts": [ 17 | "background.js" 18 | ] 19 | }, 20 | 21 | "browser_action": { 22 | "default_icon": "manifest-res/buttercup-256.png", 23 | "default_popup": "popup.html#/" 24 | }, 25 | 26 | "content_scripts" : [ 27 | { 28 | "matches": ["http://*/*", "https://*/*"], 29 | "run_at": "document_end", 30 | "all_frames": true, 31 | "js": ["tab.js"] 32 | } 33 | ], 34 | 35 | "permissions": [ 36 | "clipboardWrite", 37 | "http://*/*", 38 | "https://*/*", 39 | "storage", 40 | "tabs", 41 | "unlimitedStorage" 42 | ], 43 | 44 | "web_accessible_resources": [ 45 | "*.png", 46 | "*.jpg" 47 | ], 48 | 49 | "applications": { 50 | "gecko": { 51 | "id": "{10e7d273-2e63-47c9-82af-76c45dc1b624}" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /source/background/services/notifications.ts: -------------------------------------------------------------------------------- 1 | import { getSyncValue, setSyncValue } from "./storage.js"; 2 | import { SyncStorageItem } from "../types.js"; 3 | import { NOTIFICATION_NAMES } from "../../shared/notifications/index.js"; 4 | import { createNewTab, getExtensionURL } from "../../shared/library/extension.js"; 5 | 6 | async function getPendingNotifications(): Promise> { 7 | const existingNotificationsRaw = await getSyncValue(SyncStorageItem.Notifications); 8 | const existingNotifications = existingNotificationsRaw ? existingNotificationsRaw.split(",") : []; 9 | return NOTIFICATION_NAMES.filter((name) => !existingNotifications.includes(name)); 10 | } 11 | 12 | export async function markNotificationRead(name: string): Promise { 13 | const existingNotificationsRaw = await getSyncValue(SyncStorageItem.Notifications); 14 | const notifications = existingNotificationsRaw ? existingNotificationsRaw.split(",") : []; 15 | if (!notifications.includes(name)) { 16 | notifications.push(name); 17 | } 18 | await setSyncValue(SyncStorageItem.Notifications, notifications.join(",")); 19 | } 20 | 21 | export async function showPendingNotifications(): Promise { 22 | const notifications = await getPendingNotifications(); 23 | if (notifications.length <= 0) return; 24 | await createNewTab(getExtensionURL(`full.html#/notifications?notifications=${notifications.join(",")}`)); 25 | } 26 | -------------------------------------------------------------------------------- /resources/manifest.v3.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | 4 | "name": "Buttercup", 5 | "description": "Browser extension for Buttercup, the secure and easy-to-use password manager.", 6 | "version": "0.0.0", 7 | 8 | "icons": { 9 | "256": "manifest-res/buttercup-256.png", 10 | "128": "manifest-res/buttercup-128.png", 11 | "48": "manifest-res/buttercup-48.png", 12 | "16": "manifest-res/buttercup-16.png" 13 | }, 14 | 15 | "background": { 16 | "service_worker": "background.js" 17 | }, 18 | 19 | "action": { 20 | "default_title": "Buttercup", 21 | "default_icon": "manifest-res/buttercup-256.png", 22 | "default_popup": "/popup.html#/" 23 | }, 24 | 25 | "content_scripts" : [ 26 | { 27 | "matches": ["http://*/*", "https://*/*"], 28 | "run_at": "document_end", 29 | "all_frames": true, 30 | "js": ["tab.js"] 31 | } 32 | ], 33 | 34 | "permissions": [ 35 | "clipboardWrite", 36 | "storage", 37 | "tabs", 38 | "unlimitedStorage" 39 | ], 40 | 41 | "web_accessible_resources": [ 42 | { 43 | "resources": ["*.png", "*.jpg"], 44 | "matches": ["http://*/*", "https://*/*"] 45 | }, 46 | { 47 | "resources": ["popup.html"], 48 | "matches": ["http://*/*", "https://*/*"] 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /source/shared/components/RouteError.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { useRouteError } from "react-router-dom"; 4 | import { Callout, Intent } from "@blueprintjs/core"; 5 | import { t } from "../i18n/trans.js"; 6 | 7 | const ErrorCallout = styled(Callout)` 8 | margin: 4px; 9 | box-sizing: border-box; 10 | width: calc(100% - 8px) !important; 11 | height: calc(100% - 8px) !important; 12 | overflow: scroll; 13 | `; 14 | const PreForm = styled.pre` 15 | margin: 0px; 16 | `; 17 | 18 | function stripBlanks(txt = "") { 19 | return txt 20 | .split(/(\r\n|\n)/g) 21 | .filter(ln => ln.trim().length > 0) 22 | .join("\n"); 23 | } 24 | 25 | export function RouteError() { 26 | const err = useRouteError() as Error | null; 27 | if (!err) return null; 28 | return ( 29 | 30 |

{t("error.fatal-boundary")}

31 | {(!err.stack || err.stack.includes(err.message) === false) && ( 32 | 33 | {err.message} 34 | 35 | )} 36 | {err.stack && ( 37 | 38 | {stripBlanks(err.stack)} 39 | 40 | )} 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /source/full/hooks/vaultContents.ts: -------------------------------------------------------------------------------- 1 | import { useAsync } from "../../shared/hooks/async.js"; 2 | import { getVaultsTree } from "../services/vaults.js"; 3 | import { VaultsTree } from "../types.js"; 4 | 5 | function vaultTreesDiffer(existingValue: VaultsTree, newValue: VaultsTree): boolean { 6 | if (existingValue === null || newValue === null) return true; 7 | const existingVaults = Object.keys(existingValue).sort().join(","); 8 | const newVaults = Object.keys(newValue).sort().join(","); 9 | if (existingVaults !== newVaults) return true; 10 | for (const vaultID in existingValue) { 11 | // Compare groups 12 | const existingGroupMap = existingValue[vaultID].groups 13 | .map((group) => `${group.parentID}-${group.id}:${group.title}`) 14 | .sort() 15 | .join(","); 16 | const newGroupMap = newValue[vaultID].groups 17 | .map((group) => `${group.parentID}-${group.id}:${group.title}`) 18 | .sort() 19 | .join(","); 20 | if (existingGroupMap !== newGroupMap) return true; 21 | } 22 | return false; 23 | } 24 | 25 | export function useAllVaultsContents(): { 26 | error: Error | null; 27 | loading: boolean; 28 | tree: VaultsTree | null; 29 | } { 30 | const { error, loading, value } = useAsync(getVaultsTree, [], { 31 | clearOnExec: false, 32 | updateInterval: 5000, 33 | valuesDiffer: vaultTreesDiffer 34 | }); 35 | return { 36 | error, 37 | loading, 38 | tree: value ?? null 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /source/popup/components/otps/OTPItemList.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import styled from "styled-components"; 3 | import { Divider } from "@blueprintjs/core"; 4 | import { OTPItem } from "./OTPItem.js"; 5 | import { PreparedOTP } from "../../hooks/otp.js"; 6 | import { OTP } from "../../types.js"; 7 | 8 | interface OTPItemListProps { 9 | onOTPClick: (otp: OTP) => void; 10 | otps: Array; 11 | } 12 | 13 | const ButtonRow = styled.div` 14 | display: flex; 15 | flex-direction: row; 16 | justify-content: flex-end; 17 | align-items: flex-start; 18 | 19 | > button:not(:last-child) { 20 | margin-right: 6px; 21 | } 22 | `; 23 | const ScrollList = styled.div` 24 | max-height: 100%; 25 | // overflow-x: hidden; 26 | // overflow-y: scroll; 27 | display: flex; 28 | flex-direction: column; 29 | justify-content: flex-start; 30 | align-items: stretch; 31 | `; 32 | 33 | export function OTPItemList(props: OTPItemListProps) { 34 | const { 35 | onOTPClick, 36 | otps 37 | } = props; 38 | 39 | return ( 40 | <> 41 | 42 | {otps.map((otp) => ( 43 | 44 | onOTPClick(otp)} 47 | /> 48 | 49 | 50 | ))} 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /source/background/services/storage/BrowserStorageInterface.ts: -------------------------------------------------------------------------------- 1 | import { StorageInterface } from "buttercup"; 2 | import { getExtensionAPI } from "../../../shared/extension.js"; 3 | 4 | export function getSyncStorage() { 5 | return getExtensionAPI().storage.sync; 6 | } 7 | 8 | export function getNonSyncStorage() { 9 | return getExtensionAPI().storage.local; 10 | } 11 | 12 | export class BrowserStorageInterface extends StorageInterface { 13 | protected _storage: chrome.storage.StorageArea; 14 | 15 | constructor(storage: chrome.storage.StorageArea = getSyncStorage()) { 16 | super(); 17 | this._storage = storage; 18 | } 19 | 20 | get storage() { 21 | return this._storage; 22 | } 23 | 24 | async getAllKeys() { 25 | return new Promise>((resolve) => { 26 | this.storage.get(null, (allItems) => { 27 | resolve(Object.keys(allItems)); 28 | }); 29 | }); 30 | } 31 | 32 | async getValue(name: string) { 33 | return new Promise((resolve) => { 34 | this.storage.get(name, (items) => { 35 | resolve(items[name]); 36 | }); 37 | }); 38 | } 39 | 40 | async removeKey(name: string) { 41 | return new Promise((resolve) => { 42 | this.storage.remove(name, () => resolve()); 43 | }); 44 | } 45 | 46 | async setValue(name: string, value: any) { 47 | return new Promise((resolve) => { 48 | this.storage.set({ [name]: value }, () => resolve()); 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /source/full/services/credentials.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { EntryType } from "buttercup"; 3 | import { sendBackgroundMessage } from "../../shared/services/messaging.js"; 4 | import { BackgroundMessageType, SavedCredentials, UsedCredentials } from "../types.js"; 5 | 6 | export async function clearSavedCredentials(id: string): Promise { 7 | const resp = await sendBackgroundMessage({ 8 | credentialsID: id, 9 | type: BackgroundMessageType.ClearSavedCredentials 10 | }); 11 | if (resp.error) { 12 | throw new Layerr(resp.error, "Failed clearing saved credentials"); 13 | } 14 | } 15 | 16 | export async function getCredentials(): Promise> { 17 | const resp = await sendBackgroundMessage({ 18 | type: BackgroundMessageType.GetSavedCredentials 19 | }); 20 | if (resp.error) { 21 | throw new Layerr(resp.error, "Failed fetching saved credentials"); 22 | } 23 | return resp.credentials ?? []; 24 | } 25 | 26 | export async function saveCredentialsToEntry(credentials: SavedCredentials): Promise { 27 | const { entryID = null } = await sendBackgroundMessage({ 28 | sourceID: credentials.sourceID, 29 | groupID: credentials.groupID, 30 | entryID: credentials.entryID ?? undefined, 31 | entryProperties: { 32 | password: credentials.password, 33 | title: credentials.title, 34 | url: credentials.url, 35 | username: credentials.username 36 | }, 37 | entryType: EntryType.Website, 38 | type: BackgroundMessageType.SaveCredentialsToVault 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /source/popup/hooks/credentials.ts: -------------------------------------------------------------------------------- 1 | import { Layerr } from "layerr"; 2 | import { AsyncResult, useAsync } from "../../shared/hooks/async.js"; 3 | import { sendBackgroundMessage } from "../../shared/services/messaging.js"; 4 | import { BackgroundMessageType, UsedCredentials } from "../types.js"; 5 | import { useCallback } from "react"; 6 | 7 | async function getAllCredentials(): Promise> { 8 | const resp = await sendBackgroundMessage({ 9 | type: BackgroundMessageType.GetSavedCredentials 10 | }); 11 | if (resp.error) { 12 | throw new Layerr(resp.error, "Failed fetching saved credentials"); 13 | } 14 | return resp.credentials ?? []; 15 | } 16 | 17 | async function getCredentialsForID(id: string): Promise { 18 | const resp = await sendBackgroundMessage({ 19 | credentialsID: id, 20 | type: BackgroundMessageType.GetSavedCredentialsForID 21 | }); 22 | if (resp.error) { 23 | throw new Layerr(resp.error, "Failed fetching saved credentials"); 24 | } 25 | return resp.credentials?.[0] ?? null; 26 | } 27 | 28 | export function useAllLoginCredentials(): AsyncResult> { 29 | const getCredentials = useCallback(() => getAllCredentials(), []); 30 | const result = useAsync(getCredentials, [getCredentials]); 31 | return result; 32 | } 33 | 34 | export function useLoginCredentials(loginID: string | null): AsyncResult { 35 | const getCredentials = useCallback(async () => (loginID ? await getCredentialsForID(loginID) : null), [loginID]); 36 | const result = useAsync(getCredentials, [getCredentials]); 37 | return result; 38 | } 39 | -------------------------------------------------------------------------------- /source/full/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { Divider } from "@blueprintjs/core"; 4 | import { ChildElements } from "../types.js"; 5 | 6 | import BUTTERCUP_LOGO from "../../../resources/buttercup-128.png"; 7 | 8 | interface LayoutProps { 9 | children: ChildElements; 10 | title: string; 11 | } 12 | 13 | const ContentContainer = styled.div` 14 | padding: 1rem; 15 | `; 16 | const Header = styled.div` 17 | margin: 0.5rem 1rem 0; 18 | padding: 0.3rem 0; 19 | display: flex; 20 | justify-content: flex-start; 21 | align-items: center; 22 | `; 23 | const MainContent = styled.div` 24 | width: 100vw; 25 | min-height: 100vh; 26 | padding: 3rem 0; 27 | `; 28 | const Title = styled.h1` 29 | margin: 0px 0px 4px 0px; 30 | padding: 0; 31 | font-size: 18px; 32 | flex: 1; 33 | `; 34 | const TitleImage = styled.img` 35 | width: 28px; 36 | height: 28px; 37 | margin-bottom: 3px; 38 | margin-right: 6px; 39 | `; 40 | const Wrapper = styled.div` 41 | width: 680px; 42 | margin: 0 auto; 43 | display: flex; 44 | flex-direction: column; 45 | @media screen and (max-width: 700px) { 46 | width: 100%; 47 | } 48 | `; 49 | 50 | export function Layout({ children, title }: LayoutProps) { 51 | return ( 52 | 53 | 54 |
55 | 56 | {title} 57 |
58 | 59 | {children} 60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /source/background/services/config.ts: -------------------------------------------------------------------------------- 1 | import { getSyncValue, setSyncValue } from "./storage.js"; 2 | import { Configuration, InputButtonType, SyncStorageItem } from "../types.js"; 3 | import { naiveClone } from "../../shared/library/clone.js"; 4 | 5 | const DEFAULTS: Configuration = { 6 | entryIcons: true, 7 | inputButtonDefault: InputButtonType.LargeButton, 8 | saveNewLogins: true, 9 | theme: "light", 10 | useSystemTheme: true 11 | }; 12 | 13 | let __lastConfig: Configuration | null = null; 14 | 15 | export function getConfig(): Configuration { 16 | if (!__lastConfig) { 17 | throw new Error("No configuration available"); 18 | } 19 | return __lastConfig; 20 | } 21 | 22 | export async function initialise() { 23 | __lastConfig = await updateConfigWithDefaults(); 24 | } 25 | 26 | export async function updateConfigValue(key: T, value: Configuration[T]): Promise { 27 | const configRaw = await getSyncValue(SyncStorageItem.Configuration); 28 | const config = configRaw ? JSON.parse(configRaw) : naiveClone(DEFAULTS); 29 | config[key] = value; 30 | __lastConfig = config; 31 | await setSyncValue(SyncStorageItem.Configuration, JSON.stringify(config)); 32 | } 33 | 34 | async function updateConfigWithDefaults(): Promise { 35 | let configRaw = await getSyncValue(SyncStorageItem.Configuration); 36 | const config = configRaw ? JSON.parse(configRaw) : { ...DEFAULTS }; 37 | for (const key in DEFAULTS) { 38 | if (typeof config[key] === "undefined") { 39 | config[key] = DEFAULTS[key]; 40 | } 41 | } 42 | await setSyncValue(SyncStorageItem.Configuration, JSON.stringify(config)); 43 | return config; 44 | } 45 | -------------------------------------------------------------------------------- /source/shared/library/extension.ts: -------------------------------------------------------------------------------- 1 | import { getExtensionAPI } from "../extension.js"; 2 | 3 | const NOOP = () => {}; 4 | 5 | export async function createNewTab(url: string): Promise { 6 | const browser = getExtensionAPI(); 7 | if (!browser.tabs) { 8 | // Handle non-background scripts 9 | browser.runtime.sendMessage({ type: "open-tab", url }); 10 | return null; 11 | } 12 | return new Promise((resolve) => chrome.tabs.create({ url }, resolve)); 13 | } 14 | 15 | export function closeCurrentTab() { 16 | const browser = getExtensionAPI(); 17 | browser.tabs.getCurrent((tab) => { 18 | if (!tab?.id) return; 19 | browser.tabs.remove(tab.id, NOOP); 20 | }); 21 | } 22 | 23 | export async function getAllTabs(): Promise> { 24 | const browser = getExtensionAPI(); 25 | return new Promise>((resolve) => { 26 | browser.tabs.query({ discarded: false }, (tabs) => { 27 | resolve(tabs); 28 | }); 29 | }); 30 | } 31 | 32 | export async function getCurrentTab(): Promise { 33 | const browser = getExtensionAPI(); 34 | return new Promise((resolve) => { 35 | browser.tabs.query({ active: true, currentWindow: true }, (tabs) => { 36 | resolve(tabs[0]); 37 | }); 38 | }); 39 | } 40 | 41 | export function getExtensionURL(path: string): string { 42 | return getExtensionAPI().runtime.getURL(path); 43 | } 44 | 45 | export async function sendTabMessage(tabID: number, message: any) { 46 | return new Promise((resolve) => { 47 | chrome.tabs.sendMessage(tabID, message, (response) => { 48 | resolve(response); 49 | }); 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /source/popup/services/tab.ts: -------------------------------------------------------------------------------- 1 | import { SearchResult } from "buttercup"; 2 | import { Intent } from "@blueprintjs/core"; 3 | import { otpURIToDigits } from "../../shared/library/otp.js"; 4 | import { getToaster } from "../../shared/services/notifications.js"; 5 | import { localisedErrorMessage } from "../../shared/library/error.js"; 6 | import { t } from "../../shared/i18n/trans.js"; 7 | import { OTP, TabEvent, TabEventType } from "../types.js"; 8 | 9 | export function sendEntryResultToTabForInput(formID: string, entry: SearchResult): void { 10 | if (!formID) { 11 | throw new Error("No form ID found for dialog"); 12 | } 13 | sendTabEvent({ 14 | formID, 15 | inputDetails: { 16 | username: entry.properties.username ?? null, 17 | password: entry.properties.password ?? null 18 | }, 19 | type: TabEventType.InputDetails 20 | }); 21 | } 22 | 23 | export function sendOTPToTabForInput(formID: string, otp: OTP): void { 24 | if (!formID) { 25 | throw new Error("No form ID found for dialog"); 26 | } 27 | let code: string = ""; 28 | try { 29 | code = otpURIToDigits(otp.otpURL); 30 | } catch (err) { 31 | console.error(err); 32 | getToaster().show({ 33 | intent: Intent.DANGER, 34 | message: t("error.otp-generate", { message: localisedErrorMessage(err) }), 35 | timeout: 10000 36 | }); 37 | } 38 | sendTabEvent({ 39 | formID, 40 | inputDetails: { 41 | otp: code 42 | }, 43 | type: TabEventType.InputDetails 44 | }); 45 | } 46 | 47 | function sendTabEvent(event: TabEvent, target: MessageEventSource = window.parent): void { 48 | (target as Window).postMessage(event, "*"); 49 | } 50 | -------------------------------------------------------------------------------- /source/background/services/init.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "eventemitter3"; 2 | import { log } from "./log.js"; 3 | import { initialise as initialiseMessaging } from "./messaging.js"; 4 | import { initialise as initialiseStorage } from "./storage.js"; 5 | import { initialise as initialiseConfig } from "./config.js"; 6 | import { generateKeys } from "./cryptoKeys.js"; 7 | import { initialise as initialiseI18n } from "../../shared/i18n/trans.js"; 8 | import { getLanguage } from "../../shared/library/i18n.js"; 9 | import { showPendingNotifications } from "./notifications.js"; 10 | 11 | enum Initialisation { 12 | Complete = "complete", 13 | Idle = "idle", 14 | Running = "running" 15 | } 16 | 17 | const __initEE = new EventEmitter(); 18 | let __initialisation: Initialisation = Initialisation.Idle; 19 | 20 | export async function initialise(): Promise { 21 | if (__initialisation !== Initialisation.Idle) return; 22 | __initialisation = Initialisation.Running; 23 | log("initialising"); 24 | initialiseMessaging(); 25 | await initialiseStorage(); 26 | await initialiseConfig(); 27 | await initialiseI18n(getLanguage()); 28 | await generateKeys(); 29 | log("initialisation complete"); 30 | __initialisation = Initialisation.Complete; 31 | __initEE.emit("initialised"); 32 | await showPendingNotifications(); 33 | } 34 | 35 | export async function resetInitialisation(): Promise { 36 | log("resetting initialisation"); 37 | __initialisation = Initialisation.Idle; 38 | await initialise(); 39 | } 40 | 41 | export async function waitForInitialisation(): Promise { 42 | return new Promise((resolve) => { 43 | if (__initialisation === Initialisation.Complete) return resolve(); 44 | __initEE.once("initialised", resolve); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /source/shared/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import styled from "styled-components"; 3 | import { Callout, Intent } from "@blueprintjs/core"; 4 | import { t } from "../i18n/trans.js"; 5 | 6 | const ErrorCallout = styled(Callout)` 7 | margin: 4px; 8 | box-sizing: border-box; 9 | width: calc(100% - 8px) !important; 10 | height: calc(100% - 8px) !important; 11 | overflow: scroll; 12 | `; 13 | const PreForm = styled.pre` 14 | margin: 0px; 15 | `; 16 | 17 | function stripBlanks(txt = "") { 18 | return txt 19 | .split(/(\r\n|\n)/g) 20 | .filter(ln => ln.trim().length > 0) 21 | .join("\n"); 22 | } 23 | 24 | export class ErrorBoundary extends Component { 25 | static getDerivedStateFromError(error: Error) { 26 | return { error }; 27 | } 28 | 29 | state: { 30 | error: null | Error; 31 | errorStack: string | null; 32 | } = { 33 | error: null, 34 | errorStack: null 35 | }; 36 | 37 | componentDidCatch(error: Error, errorInfo) { 38 | this.setState({ errorStack: errorInfo.componentStack || null }); 39 | } 40 | 41 | render() { 42 | if (!this.state.error) { 43 | return this.props.children || null; 44 | } 45 | return ( 46 | 47 |

{t("error.fatal-boundary")}

48 | 49 | {this.state.error.toString()} 50 | 51 | {this.state.errorStack && ( 52 | 53 | {stripBlanks(this.state.errorStack)} 54 | 55 | )} 56 |
57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /source/full/components/pages/connect/CodeInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { KeyboardEvent, useCallback } from "react"; 2 | import { Button, ControlGroup, InputGroup, Intent } from "@blueprintjs/core"; 3 | import styled from "styled-components"; 4 | import { t } from "../../../../shared/i18n/trans.js"; 5 | 6 | interface CodeInputProps { 7 | authenticating: boolean; 8 | onChange: (newValue: string) => void; 9 | onSubmit: () => void; 10 | value: string; 11 | } 12 | 13 | const Container = styled.div` 14 | width: 100%; 15 | margin-top: 32px; 16 | display: flex; 17 | flex-direction: row; 18 | justify-content: center; 19 | align-items: center; 20 | `; 21 | 22 | export function CodeInput(props: CodeInputProps) { 23 | const handleKeyPress = useCallback((event: KeyboardEvent) => { 24 | if ((event.key === "Enter" || event.keyCode === 13) && !event.ctrlKey && !event.shiftKey) { 25 | props.onSubmit(); 26 | } 27 | }, [props.onSubmit]); 28 | return ( 29 | 30 | 31 | props.onChange(evt.target.value)} 37 | onKeyDown={handleKeyPress} 38 | placeholder={t("connect-page.code-plc")} 39 | type="password" 40 | value={props.value} 41 | /> 42 | 55 | 56 | 57 | )} 58 | /> 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /source/tab/ui/saveDialog.ts: -------------------------------------------------------------------------------- 1 | import { el, mount, unmount } from "redom"; 2 | import { getExtensionURL } from "../../shared/library/extension.js"; 3 | import { BRAND_COLOUR_DARK } from "../../shared/symbols.js"; 4 | 5 | interface LastSaveDialog { 6 | cleanup: () => void; 7 | dialog: HTMLElement; 8 | loginID: string; 9 | } 10 | 11 | const CLEAR_STYLES = { 12 | margin: "0px", 13 | minWidth: "0px", 14 | minHeight: "0px", 15 | padding: "0px" 16 | }; 17 | const DIALOG_WIDTH = 380; 18 | const DIALOG_HEIGHT = 230; 19 | 20 | let __popup: LastSaveDialog | null = null; 21 | 22 | function buildNewSaveDialog(loginID: string) { 23 | const dialogURL = getExtensionURL(`popup.html#/save-dialog?login=${loginID}`); 24 | const frame = el("iframe", { 25 | style: { 26 | width: "100%", 27 | height: "100%" 28 | }, 29 | src: dialogURL, 30 | frameBorder: "0" 31 | }); 32 | const container = el( 33 | "div", 34 | { 35 | style: { 36 | ...CLEAR_STYLES, 37 | background: "#fff", 38 | borderRadius: "6px", 39 | overflow: "hidden", 40 | border: `2px solid ${BRAND_COLOUR_DARK}`, 41 | width: `${DIALOG_WIDTH}px`, 42 | height: `${DIALOG_HEIGHT}px`, 43 | minWidth: `${DIALOG_WIDTH}px`, 44 | position: "absolute", 45 | top: "15px", 46 | right: "15px", 47 | zIndex: 9999999 48 | } 49 | }, 50 | frame 51 | ); 52 | mount(document.body, container); 53 | __popup = { 54 | cleanup: () => {}, 55 | dialog: container, 56 | loginID 57 | }; 58 | } 59 | 60 | export function closeDialog() { 61 | if (!__popup) return; 62 | __popup.cleanup(); 63 | unmount(document.body, __popup.dialog); 64 | __popup = null; 65 | } 66 | 67 | export function openDialog(loginID: string) { 68 | if (__popup && __popup.loginID === loginID) return; 69 | if (__popup) { 70 | closeDialog(); 71 | } 72 | buildNewSaveDialog(loginID); 73 | } 74 | -------------------------------------------------------------------------------- /source/full/components/pages/connect/ConnectPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | import { Intent } from "@blueprintjs/core"; 3 | import { Layout } from "../../Layout.js"; 4 | import { t } from "../../../../shared/i18n/trans.js"; 5 | import { CodeInput } from "./CodeInput.js"; 6 | import { sendBackgroundMessage } from "../../../../shared/services/messaging.js"; 7 | import { getToaster } from "../../../../shared/services/notifications.js"; 8 | import { closeCurrentTab } from "../../../../shared/library/extension.js"; 9 | import { localisedErrorMessage } from "../../../../shared/library/error.js"; 10 | import { BackgroundMessageType } from "../../../types.js"; 11 | 12 | export function ConnectPage() { 13 | const [code, setCode] = useState(""); 14 | const [authenticating, setAuthenticating] = useState(false); 15 | const handleSubmitCode = useCallback(async () => { 16 | if (!code) return; 17 | setAuthenticating(true); 18 | sendBackgroundMessage({ type: BackgroundMessageType.AuthenticateDesktopConnection, code }) 19 | .then(() => { 20 | getToaster().show({ 21 | intent: Intent.SUCCESS, 22 | message: t("connect-page.auth-success"), 23 | timeout: 3000 24 | }); 25 | setTimeout(() => { 26 | closeCurrentTab(); 27 | }, 3000); 28 | }) 29 | .catch(err => { 30 | console.error(err); 31 | getToaster().show({ 32 | intent: Intent.DANGER, 33 | message: t("connect-page.auth-error", { message: localisedErrorMessage(err) }), 34 | timeout: 10000 35 | }); 36 | setAuthenticating(false); 37 | }); 38 | }, [code]); 39 | return ( 40 | 41 |

{t("connect-page.description")}

42 |

{t("connect-page.instruction")}

43 | 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /source/tab/services/messaging.ts: -------------------------------------------------------------------------------- 1 | import { FORM } from "../state/form.js"; 2 | import { fillFormDetails } from "./form.js"; 3 | import { closeDialog } from "../ui/saveDialog.js"; 4 | import { getExtensionAPI } from "../../shared/extension.js"; 5 | import { FrameEvent, FrameEventType, TabEvent, TabEventType } from "../types.js"; 6 | 7 | let __framesChannel: BroadcastChannel; 8 | 9 | export function broadcastFrameMessage(event: FrameEvent): void { 10 | __framesChannel.postMessage(event); 11 | } 12 | 13 | export async function initialise() { 14 | __framesChannel = new BroadcastChannel("frames:all"); 15 | __framesChannel.addEventListener("message", handleFramesBroadcast); 16 | const browser = getExtensionAPI(); 17 | browser.runtime.onMessage.addListener(handleTabMessage); 18 | } 19 | 20 | function handleFramesBroadcast(event: MessageEvent) { 21 | const { type } = event.data; 22 | if (type === FrameEventType.FillForm) { 23 | const { formID } = event.data; 24 | if (formID && formID === FORM.currentFormID && FORM.currentLoginTarget) { 25 | fillFormDetails(event.data); 26 | } 27 | } 28 | } 29 | 30 | function handleTabMessage(payload: unknown) { 31 | if ( 32 | !payload || 33 | typeof payload !== "object" || 34 | Object.values(TabEventType).includes((payload as any).type) === false 35 | ) { 36 | return; 37 | } 38 | const event = payload as TabEvent; 39 | if (event.type === TabEventType.CloseSaveDialog) { 40 | closeDialog(); 41 | } 42 | } 43 | 44 | export function listenForTabEvents(callback: (event: TabEvent) => void) { 45 | window.addEventListener("message", (event: MessageEvent) => { 46 | if (event.data?.type && Object.values(TabEventType).includes(event.data?.type)) { 47 | callback({ 48 | ...(event.data as TabEvent), 49 | source: event.source ?? undefined 50 | }); 51 | } 52 | }); 53 | } 54 | 55 | export function sendTabEvent(event: TabEvent, destination: MessageEventSource): void { 56 | const payload: TabEvent = { 57 | ...event, 58 | sourceURL: `${window.location.href}` 59 | }; 60 | if (destination instanceof Window) { 61 | destination.postMessage(payload, "*"); 62 | } else { 63 | destination.postMessage(payload); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /source/tab/services/formDetection.ts: -------------------------------------------------------------------------------- 1 | import { LoginTarget, LoginTargetFeature, getLoginTargets } from "@buttercup/locust"; 2 | import { attachLaunchButton } from "../ui/launch.js"; 3 | import { watchCredentialsOnTarget } from "./logins/watcher.js"; 4 | import { processTargetAutoLogin } from "./autoLogin.js"; 5 | import { InputType } from "../types.js"; 6 | import { getConfig } from "./config.js"; 7 | 8 | const TARGET_SEARCH_INTERVAL = 1000; 9 | 10 | function filterLoginTarget(_: LoginTargetFeature, element: HTMLElement): boolean { 11 | if (element.dataset.bcup === "attached") { 12 | return false; 13 | } 14 | return true; 15 | } 16 | 17 | function onIdentifiedTarget(callback: (target: LoginTarget) => void) { 18 | const locatedForms: Array = []; 19 | const findTargets = () => { 20 | getLoginTargets(document, filterLoginTarget) 21 | .filter((target) => locatedForms.includes(target.form) === false) 22 | .forEach((target) => { 23 | locatedForms.push(target.form); 24 | setTimeout(() => { 25 | callback(target); 26 | }, 0); 27 | }); 28 | }; 29 | const checkInterval = setInterval(findTargets, TARGET_SEARCH_INTERVAL); 30 | setTimeout(findTargets, 0); 31 | return { 32 | remove: () => { 33 | clearInterval(checkInterval); 34 | locatedForms.splice(0, locatedForms.length); 35 | } 36 | }; 37 | } 38 | 39 | export async function waitAndAttachLaunchButtons( 40 | onInputActivate: (input: HTMLInputElement, loginTarget: LoginTarget, inputType: InputType) => void 41 | ) { 42 | const config = await getConfig(); 43 | onIdentifiedTarget((loginTarget: LoginTarget) => { 44 | const { otpField, usernameField, passwordField } = loginTarget; 45 | if (otpField) { 46 | attachLaunchButton(otpField, config.inputButtonDefault, (el) => 47 | onInputActivate(el, loginTarget, InputType.OTP) 48 | ); 49 | } 50 | if (passwordField) { 51 | attachLaunchButton(passwordField, config.inputButtonDefault, (el) => 52 | onInputActivate(el, loginTarget, InputType.UserPassword) 53 | ); 54 | } 55 | if (usernameField) { 56 | attachLaunchButton(usernameField, config.inputButtonDefault, (el) => 57 | onInputActivate(el, loginTarget, InputType.UserPassword) 58 | ); 59 | } 60 | watchCredentialsOnTarget(loginTarget); 61 | processTargetAutoLogin(loginTarget).catch(console.error); 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /source/tab/services/LoginTracker.ts: -------------------------------------------------------------------------------- 1 | import { ulid } from "ulidx"; 2 | import EventEmitter from "eventemitter3"; 3 | import { getCurrentTitle, getCurrentURL } from "../library/page.js"; 4 | import { LoginTarget } from "@buttercup/locust"; 5 | 6 | interface Connection { 7 | id: string; 8 | loginTarget: LoginTarget; 9 | entry: boolean; 10 | username: string; 11 | password: string; 12 | _username: string; 13 | _password: string; 14 | } 15 | 16 | interface LoginTrackerEvents { 17 | credentialsChanged: (event: { id: string; username: string; password: string; entry: boolean }) => void; 18 | } 19 | 20 | let __sharedTracker: LoginTracker | null = null; 21 | 22 | export class LoginTracker extends EventEmitter { 23 | protected _connections: Array = []; 24 | protected _title = getCurrentTitle(); 25 | protected _url = getCurrentURL(); 26 | 27 | get title() { 28 | return this._title; 29 | } 30 | 31 | get url() { 32 | return this._url; 33 | } 34 | 35 | getConnection(loginTarget: LoginTarget): Connection | null { 36 | return ( 37 | this._connections.find( 38 | (conn) => conn.loginTarget === loginTarget || conn.loginTarget.form === loginTarget.form 39 | ) || null 40 | ); 41 | } 42 | 43 | registerConnection(loginTarget: LoginTarget) { 44 | const _this = this; 45 | const connection: Connection = { 46 | id: ulid(), 47 | loginTarget, 48 | entry: false, 49 | _username: "", 50 | _password: "", 51 | get username() { 52 | return connection._username; 53 | }, 54 | get password() { 55 | return connection._password; 56 | }, 57 | set username(un) { 58 | connection._username = un; 59 | _this.emit("credentialsChanged", { 60 | id: connection.id, 61 | username: connection.username, 62 | password: connection.password, 63 | entry: connection.entry 64 | }); 65 | }, 66 | set password(pw) { 67 | connection._password = pw; 68 | _this.emit("credentialsChanged", { 69 | id: connection.id, 70 | username: connection.username, 71 | password: connection.password, 72 | entry: connection.entry 73 | }); 74 | } 75 | }; 76 | this._connections.push(connection); 77 | } 78 | } 79 | 80 | export function getSharedTracker(): LoginTracker { 81 | if (!__sharedTracker) { 82 | __sharedTracker = new LoginTracker(); 83 | } 84 | return __sharedTracker; 85 | } 86 | -------------------------------------------------------------------------------- /source/popup/components/vaults/VaultItemList.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useCallback } from "react"; 2 | import styled from "styled-components"; 3 | import { Divider, Intent } from "@blueprintjs/core"; 4 | import { VaultItem } from "./VaultItem.js"; 5 | import { promptLockVault, promptUnlockVault } from "../../queries/desktop.js"; 6 | import { getToaster } from "../../../shared/services/notifications.js"; 7 | import { t } from "../../../shared/i18n/trans.js"; 8 | import { localisedErrorMessage } from "../../../shared/library/error.js"; 9 | import { VaultSourceDescription } from "../../types.js"; 10 | 11 | interface VaultItemListProps { 12 | vaults: Array; 13 | } 14 | 15 | const ScrollList = styled.div` 16 | max-height: 100%; 17 | display: flex; 18 | flex-direction: column; 19 | justify-content: flex-start; 20 | align-items: stretch; 21 | `; 22 | 23 | export function VaultItemList(props: VaultItemListProps) { 24 | const handleVaultLockClick = useCallback((vault: VaultSourceDescription) => { 25 | promptLockVault(vault.id) 26 | .then(locked => { 27 | if (locked) { 28 | getToaster().show({ 29 | intent: Intent.SUCCESS, 30 | message: t("popup.vault.locking.success", { vault: vault.name }), 31 | timeout: 4000 32 | }); 33 | } 34 | }) 35 | .catch(err => { 36 | console.error(err); 37 | getToaster().show({ 38 | intent: Intent.DANGER, 39 | message: t("popup.vault.locking.error", { message: localisedErrorMessage(err) }), 40 | timeout: 10000 41 | }); 42 | }); 43 | }, []); 44 | const handleVaultUnlockClick = useCallback((vault: VaultSourceDescription) => { 45 | promptUnlockVault(vault.id).catch(err => { 46 | console.error(err); 47 | getToaster().show({ 48 | intent: Intent.DANGER, 49 | message: t("popup.vault.unlocking.error", { message: localisedErrorMessage(err) }), 50 | timeout: 10000 51 | }); 52 | }); 53 | }, []); 54 | return ( 55 | <> 56 | 57 | {props.vaults.map((vault) => ( 58 | 59 | handleVaultLockClick(vault)} 61 | onUnlockClick={() => handleVaultUnlockClick(vault)} 62 | vault={vault} 63 | /> 64 | 65 | 66 | ))} 67 | 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /source/full/components/pages/NotificationsPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo, useState } from "react"; 2 | import { useLoaderData } from "react-router-dom"; 3 | import { Icon, Intent, Tab, Tabs } from "@blueprintjs/core"; 4 | import { NOTIFICATIONS } from "../../../shared/notifications/index.js"; 5 | import { Layout } from "../Layout.js"; 6 | import { t } from "../../../shared/i18n/trans.js"; 7 | import { updateReadNotifications } from "../../services/notifications.js"; 8 | import { getToaster } from "../../../shared/services/notifications.js"; 9 | import { localisedErrorMessage } from "../../../shared/library/error.js"; 10 | 11 | export function NotificationsPage() { 12 | const { notifications: notificationsRaw = "" } = useLoaderData() as { 13 | notifications: string 14 | }; 15 | const notificationKeys = useMemo(() => notificationsRaw.split(","), [notificationsRaw]); 16 | const notifications = useMemo(() => notificationKeys.map(key => NOTIFICATIONS[key]), [notificationKeys]); 17 | const [currentTab, setCurrentTab] = useState(null); 18 | const [readNotifications, setReadNotifications] = useState>([]); 19 | const handleTabChange = useCallback((newTabID: string) => { 20 | setReadNotifications(current => [...new Set([ 21 | ...current, 22 | newTabID 23 | ])]); 24 | setCurrentTab(newTabID); 25 | const key = Object.keys(NOTIFICATIONS).find(nKey => NOTIFICATIONS[nKey][0] === newTabID); 26 | if (!key) return; 27 | updateReadNotifications(key).catch(err => { 28 | console.error(err); 29 | getToaster().show({ 30 | intent: Intent.DANGER, 31 | message: t("error.generic", { message: localisedErrorMessage(err) }), 32 | timeout: 10000 33 | }); 34 | }); 35 | }, []); 36 | useEffect(() => { 37 | if (currentTab || notifications.length <= 0) return; 38 | handleTabChange(notifications[0][0]); 39 | }, [currentTab, handleTabChange, notifications]); 40 | return ( 41 | 42 | 43 | {notifications.map(([nameKey, Component]) => ( 44 | 49 |   50 | {t(nameKey)} 51 | 52 | )} 53 | panel={} 54 | /> 55 | ))} 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /source/background/services/storage.ts: -------------------------------------------------------------------------------- 1 | import { log } from "./log.js"; 2 | import { BrowserStorageInterface, getNonSyncStorage, getSyncStorage } from "./storage/BrowserStorageInterface.js"; 3 | import { LocalStorageItem, SyncStorageItem } from "../types.js"; 4 | 5 | const VALID_LOCAL_KEYS = Object.values(LocalStorageItem); 6 | const VALID_SYNC_KEYS = Object.values(SyncStorageItem); 7 | 8 | export async function clearLocalStorage(): Promise { 9 | const localStorage = getLocalStorage(); 10 | const syncStorage = getSynchronisedStorage(); 11 | const keys = await localStorage.getAllKeys(); 12 | for (const key of keys) { 13 | log(`clearing local storage key: ${key}`); 14 | await localStorage.removeKey(key); 15 | } 16 | await syncStorage.removeKey(SyncStorageItem.Notifications); 17 | } 18 | 19 | function getLocalStorage(): BrowserStorageInterface { 20 | return new BrowserStorageInterface(getNonSyncStorage()); 21 | } 22 | 23 | export async function getLocalValue(key: LocalStorageItem): Promise { 24 | return getLocalStorage().getValue(key) ?? null; 25 | } 26 | 27 | export async function getSyncValue(key: SyncStorageItem): Promise { 28 | return getSynchronisedStorage().getValue(key) ?? null; 29 | } 30 | 31 | function getSynchronisedStorage(): BrowserStorageInterface { 32 | return new BrowserStorageInterface(getSyncStorage()); 33 | } 34 | 35 | export async function initialise() { 36 | const localStorage = getLocalStorage(); 37 | { 38 | const keys = await localStorage.getAllKeys(); 39 | for (const key of keys) { 40 | const valid = VALID_LOCAL_KEYS.find((local) => key === local || key.indexOf(local) === 0); 41 | if (!valid) { 42 | log(`remove unrecognised local storage key: ${key}`); 43 | await localStorage.removeKey(key); 44 | } 45 | } 46 | } 47 | const syncStorage = getSynchronisedStorage(); 48 | { 49 | const keys = await syncStorage.getAllKeys(); 50 | for (const key of keys) { 51 | const valid = VALID_SYNC_KEYS.find((local) => key === local || key.indexOf(local) === 0); 52 | if (!valid) { 53 | log(`remove unrecognised sync storage key: ${key}`); 54 | await syncStorage.removeKey(key); 55 | } 56 | } 57 | } 58 | } 59 | 60 | export async function removeLocalValue(key: LocalStorageItem): Promise { 61 | await getLocalStorage().removeKey(key); 62 | } 63 | 64 | export async function removeSyncValue(key: SyncStorageItem): Promise { 65 | await getSynchronisedStorage().removeKey(key); 66 | } 67 | 68 | export async function setLocalValue(key: LocalStorageItem, value: string): Promise { 69 | return getLocalStorage().setValue(key, value); 70 | } 71 | 72 | export async function setSyncValue(key: SyncStorageItem, value: string): Promise { 73 | return getSynchronisedStorage().setValue(key, value); 74 | } 75 | -------------------------------------------------------------------------------- /source/background/services/recents.ts: -------------------------------------------------------------------------------- 1 | import { EntryID, VaultSourceID } from "buttercup"; 2 | import { ChannelQueue, TaskPriority } from "@buttercup/channel-queue"; 3 | import ms from "ms"; 4 | import { getSyncValue, setSyncValue } from "./storage.js"; 5 | import { SyncStorageItem } from "../types.js"; 6 | 7 | export interface RecentItem { 8 | entryID: EntryID; 9 | sourceID: VaultSourceID; 10 | uses: Array; 11 | } 12 | 13 | const MAX_USE_AGE = ms("30d"); 14 | 15 | let __queue: ChannelQueue | null = null; 16 | 17 | function getQueue(): ChannelQueue { 18 | if (!__queue) { 19 | __queue = new ChannelQueue(); 20 | } 21 | return __queue; 22 | } 23 | 24 | export async function getRecents(sourceIDs: Array): Promise> { 25 | const currentRecentsRaw = await getSyncValue(SyncStorageItem.RecentItems); 26 | if (!currentRecentsRaw) return []; 27 | const currentRecents = JSON.parse(currentRecentsRaw) as Array; 28 | return currentRecents.filter((recent) => sourceIDs.includes(recent.sourceID)); 29 | } 30 | 31 | function sortUses(itemA: RecentItem, itemB: RecentItem): number { 32 | if (itemA.uses.length > itemB.uses.length) return -1; 33 | if (itemB.uses.length > itemA.uses.length) return 1; 34 | return 0; 35 | } 36 | 37 | function stripOldUses(items: Array): Array { 38 | const earliestTs = Date.now() - MAX_USE_AGE; 39 | return items.reduce((output: Array, item: RecentItem) => { 40 | const hasRecentUse = item.uses.some((ts) => ts >= earliestTs); 41 | if (!hasRecentUse) return output; 42 | return [ 43 | ...output, 44 | { 45 | ...item, 46 | uses: item.uses.filter((ts) => ts >= earliestTs) 47 | } 48 | ]; 49 | }, []); 50 | } 51 | 52 | export async function trackRecentUsage(sourceID: VaultSourceID, entryID: EntryID): Promise { 53 | const channel = getQueue().channel("write"); 54 | await channel.enqueue( 55 | async () => { 56 | const currentRecentsRaw = await getSyncValue(SyncStorageItem.RecentItems); 57 | let currentRecents = currentRecentsRaw ? (JSON.parse(currentRecentsRaw) as Array) : []; 58 | let existingResult = currentRecents.find( 59 | (recent) => recent.sourceID === sourceID && recent.entryID === entryID 60 | ); 61 | if (existingResult) { 62 | existingResult.uses.unshift(Date.now()); 63 | } else { 64 | existingResult = { 65 | entryID, 66 | sourceID, 67 | uses: [Date.now()] 68 | }; 69 | currentRecents.push(existingResult); 70 | } 71 | currentRecents = stripOldUses(currentRecents); 72 | currentRecents.sort(sortUses); 73 | await setSyncValue(SyncStorageItem.RecentItems, JSON.stringify(currentRecents)); 74 | }, 75 | TaskPriority.Normal, 76 | `${sourceID}-${entryID}` 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /source/shared/themes.ts: -------------------------------------------------------------------------------- 1 | import { Colors } from "@blueprintjs/core"; 2 | 3 | export default { 4 | dark: { 5 | backgroundColor: Colors.DARK_GRAY4, 6 | backgroundFrameColor: Colors.DARK_GRAY2, 7 | listItemHover: Colors.DARK_GRAY2 8 | // codeBlock: Colors.DARK_GRAY2, 9 | // codeAccent: Colors.GREEN5, 10 | // vault: { 11 | // list: { 12 | // focusedBackgroundColor: Colors.DARK_GRAY5, 13 | // selectedBackgroundColor: Colors.TURQUOISE3, 14 | // selectedTextColor: "#fff" 15 | // }, 16 | // colors: { 17 | // divider: Colors.DARK_GRAY5, 18 | // paneDivider: Colors.GRAY3, 19 | // uiBackground: Colors.DARK_GRAY4, 20 | // mainPaneBackground: Colors.DARK_GRAY3 21 | // }, 22 | // tree: { 23 | // selectedBackgroundColor: Colors.DARK_GRAY5, 24 | // hoverBackgroundColor: "transparent", 25 | // selectedTextColor: Colors.LIGHT_GRAY5, 26 | // selectedIconColor: Colors.LIGHT_GRAY5 27 | // }, 28 | // entry: { 29 | // primaryContainer: Colors.DARK_GRAY3, 30 | // separatorTextColor: Colors.GRAY3, 31 | // separatorBorder: Colors.GRAY1, 32 | // fieldHoverBorder: Colors.GRAY1 33 | // }, 34 | // attachment: { 35 | // dropBackground: Colors.DARK_GRAY3, 36 | // dropBorder: Colors.DARK_GRAY5, 37 | // dropText: Colors.GRAY2 38 | // } 39 | // } 40 | }, 41 | light: { 42 | backgroundColor: Colors.WHITE, 43 | backgroundFrameColor: Colors.GRAY5, 44 | listItemHover: Colors.LIGHT_GRAY3 45 | // codeBlock: Colors.LIGHT_GRAY1, 46 | // codeAccent: Colors.GREEN1, 47 | // vault: { 48 | // list: { 49 | // focusedBackgroundColor: Colors.LIGHT_GRAY5, 50 | // selectedBackgroundColor: Colors.TURQUOISE3, 51 | // selectedTextColor: "#fff" 52 | // }, 53 | // colors: { 54 | // divider: Colors.LIGHT_GRAY4, 55 | // paneDivider: Colors.GRAY3, 56 | // uiBackground: "#fff", 57 | // mainPaneBackground: Colors.LIGHT_GRAY5 58 | // }, 59 | // tree: { 60 | // selectedBackgroundColor: Colors.LIGHT_GRAY2, 61 | // hoverBackgroundColor: "transparent", 62 | // selectedTextColor: Colors.DARK_GRAY1, 63 | // selectedIconColor: Colors.DARK_GRAY5 64 | // }, 65 | // entry: { 66 | // primaryContainer: Colors.LIGHT_GRAY5, 67 | // separatorTextColor: Colors.GRAY3, 68 | // separatorBorder: Colors.LIGHT_GRAY2, 69 | // fieldHoverBorder: Colors.LIGHT_GRAY1 70 | // }, 71 | // attachment: { 72 | // dropBackground: Colors.LIGHT_GRAY5, 73 | // dropBorder: Colors.LIGHT_GRAY2, 74 | // dropText: Colors.GRAY4 75 | // } 76 | // } 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /source/tab/ui/popup.ts: -------------------------------------------------------------------------------- 1 | import { el, mount, setStyle, unmount } from "redom"; 2 | import { getExtensionURL } from "../../shared/library/extension.js"; 3 | import { BRAND_COLOUR_DARK } from "../../shared/symbols.js"; 4 | import { getCurrentURL } from "../library/page.js"; 5 | import { onBodyResize } from "../library/resize.js"; 6 | import { FORM } from "../state/form.js"; 7 | import { ElementRect, InputType, PopupPage } from "../types.js"; 8 | 9 | interface LastPopup { 10 | cleanup: () => void; 11 | inputRect: ElementRect; 12 | popup: HTMLElement; 13 | } 14 | 15 | const CLEAR_STYLES = { 16 | margin: "0px", 17 | minWidth: "0px", 18 | minHeight: "0px", 19 | padding: "0px" 20 | }; 21 | const POPUP_HEIGHT = 300; 22 | const POPUP_WIDTH = 320; 23 | 24 | let __popup: LastPopup | null = null; 25 | 26 | function buildNewPopup(inputRect: ElementRect, forInputType: InputType) { 27 | const currentURL = getCurrentURL(); 28 | const formID = FORM.targetFormID || ""; 29 | const initialPage = forInputType === InputType.OTP ? PopupPage.OTPs : PopupPage.Entries; 30 | const popupURL = getExtensionURL( 31 | `popup.html#/dialog?page=${encodeURIComponent(currentURL)}&form=${formID}&initial=${initialPage}` 32 | ); 33 | const frame = el("iframe", { 34 | style: { 35 | width: "100%", 36 | height: "100%" 37 | }, 38 | src: popupURL, 39 | frameBorder: "0" 40 | }); 41 | const container = el( 42 | "div", 43 | { 44 | style: { 45 | ...CLEAR_STYLES, 46 | background: "#fff", 47 | borderRadius: "6px", 48 | overflow: "hidden", 49 | border: `2px solid ${BRAND_COLOUR_DARK}`, 50 | width: `${POPUP_WIDTH}px`, 51 | height: `${POPUP_HEIGHT}px`, 52 | minWidth: `${POPUP_WIDTH}px`, 53 | position: "absolute", 54 | zIndex: 9999999 55 | } 56 | }, 57 | frame 58 | ); 59 | mount(document.body, container); 60 | const removeBodyResizeListener = onBodyResize(() => updatePopupPosition((__popup as LastPopup).inputRect)); 61 | document.body.addEventListener("click", closePopup, false); 62 | __popup = { 63 | cleanup: () => { 64 | removeBodyResizeListener(); 65 | document.body.removeEventListener("click", closePopup, false); 66 | }, 67 | inputRect, 68 | popup: container 69 | }; 70 | updatePopupPosition(inputRect); 71 | } 72 | 73 | export function closePopup() { 74 | if (!__popup) return; 75 | __popup.cleanup(); 76 | unmount(document.body, __popup.popup); 77 | __popup = null; 78 | FORM.targetFormID = null; 79 | } 80 | 81 | export function togglePopup(inputRect: ElementRect, forInputType: InputType) { 82 | if (__popup === null) { 83 | buildNewPopup(inputRect, forInputType); 84 | } else { 85 | // Tear down 86 | closePopup(); 87 | } 88 | } 89 | 90 | export function updatePopupPosition(inputRect: ElementRect): void { 91 | if (!__popup) return; 92 | __popup.inputRect = inputRect; 93 | setStyle(__popup.popup, { 94 | left: `${inputRect.x}px`, 95 | top: `${inputRect.y + inputRect.height + 2}px` 96 | }); 97 | } 98 | -------------------------------------------------------------------------------- /source/popup/components/entries/EntryItemList.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import styled from "styled-components"; 3 | import { SearchResult } from "buttercup"; 4 | import { Divider, H4} from "@blueprintjs/core"; 5 | import { EntryItem } from "./EntryItem.js"; 6 | import { useConfig } from "../../../shared/hooks/config.js"; 7 | import { t } from "../../../shared/i18n/trans.js"; 8 | 9 | interface EntryItemListProps { 10 | entries: Array | Record>; 11 | onEntryAutoClick: (entry: SearchResult) => void; 12 | onEntryClick: (entry: SearchResult) => void; 13 | onEntryInfoClick: (entry: SearchResult) => void; 14 | } 15 | 16 | const ScrollList = styled.div` 17 | max-height: 100%; 18 | display: flex; 19 | flex-direction: column; 20 | justify-content: flex-start; 21 | align-items: stretch; 22 | `; 23 | 24 | export function EntryItemList(props: EntryItemListProps) { 25 | const { entries, onEntryAutoClick, onEntryClick, onEntryInfoClick } = props; 26 | const [config] = useConfig(); 27 | if (!config) return null; 28 | return ( 29 | 30 | {Array.isArray(entries) && ( 31 | <> 32 | {entries.map((entry) => ( 33 | 34 | onEntryAutoClick(entry)} 38 | onClick={() => onEntryClick(entry)} 39 | onInfoClick={() => onEntryInfoClick(entry)} 40 | /> 41 | 42 | 43 | ))} 44 | 45 | ) || ( 46 | <> 47 | {Object.keys(entries).map(sectionName => ( 48 | 49 | {entries[sectionName].length > 0 && ( 50 | 51 |

{t(sectionName)}

52 | {entries[sectionName].map((entry: SearchResult) => ( 53 | 54 | onEntryAutoClick(entry)} 58 | onClick={() => onEntryClick(entry)} 59 | onInfoClick={() => onEntryInfoClick(entry)} 60 | /> 61 | 62 | 63 | ))} 64 |
65 | )} 66 |
67 | ))} 68 | 69 | )} 70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /source/background/services/cryptoKeys.ts: -------------------------------------------------------------------------------- 1 | import { ulid } from "ulidx"; 2 | import { log } from "./log.js"; 3 | import { getLocalValue, setLocalValue } from "./storage.js"; 4 | import { arrayBufferToHex } from "../../shared/library/buffer.js"; 5 | import { API_KEY_ALGO, API_KEY_CURVE } from "../../shared/symbols.js"; 6 | import { LocalStorageItem } from "../types.js"; 7 | import { Layerr } from "layerr"; 8 | 9 | async function createKeys(): Promise<{ 10 | privateKey: string; 11 | publicKey: string; 12 | }> { 13 | const { privateKey, publicKey } = await window.crypto.subtle.generateKey( 14 | { 15 | name: API_KEY_ALGO, 16 | namedCurve: API_KEY_CURVE 17 | }, 18 | true, 19 | ["deriveKey"] 20 | ); 21 | log("generating public and private key pair for browser auth"); 22 | const privateKeyStr = await exportECDHKey(privateKey); 23 | const publicKeyStr = await exportECDHKey(publicKey); 24 | log("generated new browser auth keys"); 25 | return { 26 | privateKey: privateKeyStr, 27 | publicKey: publicKeyStr 28 | }; 29 | } 30 | 31 | export async function deriveSecretKey(privateKey: CryptoKey, publicKey: CryptoKey): Promise { 32 | let cryptoKey: CryptoKey; 33 | try { 34 | cryptoKey = await window.crypto.subtle.deriveKey( 35 | { 36 | name: API_KEY_ALGO, 37 | public: publicKey 38 | }, 39 | privateKey, 40 | { 41 | name: "AES-GCM", 42 | length: 256 43 | }, 44 | true, 45 | ["encrypt", "decrypt"] 46 | ); 47 | } catch (err) { 48 | throw new Layerr(err, "Failed deriving secret key"); 49 | } 50 | const exported = await window.crypto.subtle.exportKey("raw", cryptoKey); 51 | return arrayBufferToHex(exported); 52 | } 53 | 54 | async function exportECDHKey(key: CryptoKey): Promise { 55 | try { 56 | const exported = await window.crypto.subtle.exportKey("jwk", key); 57 | return JSON.stringify(exported); 58 | } catch (err) { 59 | throw new Layerr(err, "Failed exporting ECDH key"); 60 | } 61 | } 62 | 63 | export async function generateKeys(): Promise { 64 | let [apiPrivate, apiPublic, clientID] = await Promise.all([ 65 | getLocalValue(LocalStorageItem.APIPrivateKey), 66 | getLocalValue(LocalStorageItem.APIPublicKey), 67 | getLocalValue(LocalStorageItem.APIClientID) 68 | ]); 69 | if (apiPrivate && apiPublic && clientID) return; 70 | // Regenerate 71 | log("api keys missing: will generate"); 72 | const { privateKey, publicKey } = await createKeys(); 73 | clientID = ulid(); 74 | await setLocalValue(LocalStorageItem.APIPrivateKey, privateKey); 75 | await setLocalValue(LocalStorageItem.APIPublicKey, publicKey); 76 | await setLocalValue(LocalStorageItem.APIClientID, clientID); 77 | } 78 | 79 | export async function importECDHKey(key: string): Promise { 80 | let jwk: JsonWebKey; 81 | try { 82 | jwk = JSON.parse(key) as JsonWebKey; 83 | } catch (err) { 84 | throw new Layerr(err, "Failed importing ECDH key"); 85 | } 86 | const usages: Array = jwk.key_ops && jwk.key_ops.includes("deriveKey") ? ["deriveKey"] : []; 87 | return window.crypto.subtle.importKey( 88 | "jwk", 89 | jwk, 90 | { 91 | name: API_KEY_ALGO, 92 | namedCurve: API_KEY_CURVE 93 | }, 94 | true, 95 | usages 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /source/tab/services/logins/watcher.ts: -------------------------------------------------------------------------------- 1 | import { LoginTarget, LoginTargetFeature } from "@buttercup/locust"; 2 | import { onNavigate } from "on-navigate"; 3 | import { getSharedTracker } from "../LoginTracker.js"; 4 | import { getCredentialsForID, getLastSavedCredentials, transferLoginCredentials } from "./saving.js"; 5 | import { getDisabledDomains } from "./disabled.js"; 6 | import { currentDomainDisabled, getCurrentDomain } from "../../library/page.js"; 7 | import { log } from "../log.js"; 8 | import { getConfig } from "../../../shared/queries/config.js"; 9 | import { openDialog } from "../../ui/saveDialog.js"; 10 | 11 | async function checkForLoginSaveAbility(loginID?: string) { 12 | const [disabledDomains, config, used] = await Promise.all([ 13 | getDisabledDomains(), 14 | getConfig(), 15 | loginID ? getCredentialsForID(loginID, true) : getLastSavedCredentials(true) 16 | ]); 17 | if (!used || !used.promptSave || used.fromEntry) return; 18 | if (currentDomainDisabled(disabledDomains)) { 19 | log(`login available, but current domain disabled: ${getCurrentDomain()}`); 20 | return; 21 | } 22 | if (!config.saveNewLogins) return; 23 | log("saved login available, show prompt"); 24 | openDialog(used.id); 25 | } 26 | 27 | export async function initialise() { 28 | const tracker = getSharedTracker(); 29 | tracker.on("credentialsChanged", (details) => { 30 | transferLoginCredentials({ 31 | fromEntry: details.entry, 32 | id: details.id, 33 | password: details.password, 34 | promptSave: true, 35 | timestamp: Date.now(), 36 | title: tracker.title, 37 | url: tracker.url, 38 | username: details.username 39 | }); 40 | }); 41 | await checkForLoginSaveAbility(); 42 | } 43 | 44 | export function watchCredentialsOnTarget(loginTarget: LoginTarget): void { 45 | const tracker = getSharedTracker(); 46 | tracker.registerConnection(loginTarget); 47 | watchLogin( 48 | loginTarget, 49 | (username, source) => { 50 | const connection = tracker.getConnection(loginTarget); 51 | if (connection) { 52 | connection.entry = source === "fill"; 53 | connection.username = username; 54 | } 55 | }, 56 | (password, source) => { 57 | const connection = tracker.getConnection(loginTarget); 58 | if (connection) { 59 | connection.entry = source === "fill"; 60 | connection.password = password; 61 | } 62 | }, 63 | () => { 64 | const connection = tracker.getConnection(loginTarget); 65 | if (!connection) return; 66 | setTimeout(() => { 67 | checkForLoginSaveAbility(connection.id); 68 | }, 300); 69 | } 70 | ); 71 | } 72 | 73 | function watchLogin( 74 | target: LoginTarget, 75 | usernameUpdate: (value: string, source: "keypress" | "fill") => void, 76 | passwordUpdate: (value: string, source: "keypress" | "fill") => void, 77 | onSubmit: () => void 78 | ) { 79 | target.on("valueChanged", (info) => { 80 | if (info.type === LoginTargetFeature.Username) { 81 | usernameUpdate(info.value, info.source); 82 | } else if (info.type === LoginTargetFeature.Password) { 83 | passwordUpdate(info.value, info.source); 84 | } 85 | }); 86 | target.on("formSubmitted", (info) => { 87 | if (info.source === "form") { 88 | onSubmit(); 89 | } 90 | }); 91 | onNavigate(() => { 92 | onSubmit(); 93 | }); 94 | } 95 | -------------------------------------------------------------------------------- /source/popup/components/pages/AboutPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEvent, useCallback } from "react"; 2 | import styled from "styled-components"; 3 | import { Button, Callout, Classes, Intent } from "@blueprintjs/core"; 4 | import cn from "classnames"; 5 | import { t } from "../../../shared/i18n/trans.js"; 6 | import { BUILD_DATE, VERSION } from "../../../shared/library/version.js"; 7 | import { createNewTab, getExtensionURL } from "../../../shared/library/extension.js"; 8 | import { localisedErrorMessage } from "../../../shared/library/error.js"; 9 | import { getToaster } from "../../../shared/services/notifications.js"; 10 | import BUTTERCUP_LOGO from "../../../../resources/buttercup-256.png"; 11 | 12 | interface AboutPageProps {} 13 | 14 | const AboutSection = styled(Callout)` 15 | margin: 0px 12px; 16 | width: calc(100% - 24px); 17 | padding: 9px; 18 | `; 19 | const Container = styled.div` 20 | display: flex; 21 | flex-direction: column; 22 | justify-content: flex-start; 23 | align-items: stretch; 24 | 25 | > .${Classes.CALLOUT}:not(:last-child) { 26 | margin-bottom: 8px; 27 | } 28 | `; 29 | const FooterSection = styled.div` 30 | width: 100%; 31 | padding: 8px 12px; 32 | display: flex; 33 | flex-direction: column; 34 | justify-content: flex-start; 35 | align-items: center; 36 | `; 37 | const Heading = styled.h3` 38 | margin-top: 3px; 39 | margin-bottom: 5px; 40 | `; 41 | const HeadingLogo = styled.img` 42 | width: 32px; 43 | height: auto; 44 | `; 45 | const HeadingSection = styled.div` 46 | width: 100%; 47 | padding: 8px 12px; 48 | display: flex; 49 | flex-direction: column; 50 | justify-content: flex-start; 51 | align-items: center; 52 | `; 53 | const InfoTable = styled.table` 54 | width: 100%; 55 | `; 56 | 57 | export function AboutPage(_: AboutPageProps) { 58 | const handleAttributionsClick = useCallback(async (event: MouseEvent) => { 59 | try { 60 | await createNewTab(getExtensionURL("full.html#/attributions")); 61 | } catch (err) { 62 | console.error(err); 63 | getToaster().show({ 64 | intent: Intent.DANGER, 65 | message: t("error.generic", { message: localisedErrorMessage(err) }), 66 | timeout: 10000 67 | }); 68 | } 69 | }, []); 70 | return ( 71 | 72 | 73 | 74 | Buttercup Password Manager 75 | 76 | 77 | 78 | 79 | 80 | {t("about.info.title")} 81 |   82 | 83 | 84 | 85 | 86 | {t("about.info.version")} 87 | {VERSION} 88 | 89 | 90 | {t("about.info.build-date")} 91 | {BUILD_DATE} 92 | 93 | 94 | 95 | 96 | 97 | 102 | 103 | 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /source/popup/components/pages/VaultsPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import styled from "styled-components"; 3 | import { Button, ButtonGroup, Intent, NonIdealState, Spinner } from "@blueprintjs/core"; 4 | import { t } from "../../../shared/i18n/trans.js"; 5 | import { VaultItemList } from "../vaults/VaultItemList.js"; 6 | import { useDesktopConnectionState, useVaultSources } from "../../hooks/desktop.js"; 7 | import { DesktopConnectionState } from "../../types.js"; 8 | 9 | interface VaultsPageProps { 10 | onConnectClick: () => Promise; 11 | onReconnectClick: () => Promise; 12 | } 13 | 14 | const Container = styled.div` 15 | display: flex; 16 | flex-direction: column; 17 | justify-content: flex-start; 18 | align-items: stretch; 19 | `; 20 | const InvalidState = styled(NonIdealState)` 21 | margin-top: 28px; 22 | `; 23 | 24 | export function VaultsPage(props: VaultsPageProps) { 25 | const desktopState = useDesktopConnectionState(); 26 | return ( 27 | 28 | {desktopState === DesktopConnectionState.NotConnected && ( 29 | 39 | )} 40 | /> 41 | )} 42 | {desktopState === DesktopConnectionState.Connected && ( 43 | 44 | )} 45 | {desktopState === DesktopConnectionState.Pending && ( 46 | 47 | )} 48 | {desktopState === DesktopConnectionState.Error && ( 49 | 60 | )} 61 | /> 62 | )} 63 | 64 | ); 65 | } 66 | 67 | function VaultsPageList() { 68 | const sources = useVaultSources(); 69 | if (sources.length === 0) { 70 | return ( 71 | 76 | ); 77 | } 78 | return ( 79 | 82 | ); 83 | } 84 | 85 | export function VaultsPageControls() { 86 | const handleAddVaultClick = useCallback(() => { 87 | // openAddVaultPage(); 88 | }, []); 89 | return ( 90 | 91 |