Add About screen, list of recently opened vaults, category filtering

This commit is contained in:
aet 2022-01-02 00:53:57 -05:00
parent 5883adc2c1
commit d8f2cddb74
27 changed files with 1108 additions and 558 deletions

View File

@ -8,6 +8,7 @@
"design": "marked -o design.html < design.md",
"test": "node --expose-gc node_modules/mocha/bin/_mocha test/**/*.test.ts",
"repl": "node -r ts-node/register/transpile-only src/repl.ts",
"dev": "cd packages/web && yarn dev",
"bundle": "cd packages/web && yarn bundle"
},
"devDependencies": {
@ -15,20 +16,20 @@
"@types/chai-as-promised": "^7.1.4",
"@types/fs-extra": "^9.0.13",
"@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768",
"@types/node": "^16.11.9",
"@types/node": "^17.0.6",
"@types/sinon": "^10.0.6",
"@types/sinon-chai": "^3.2.6",
"@types/sinon-chai": "^3.2.8",
"@types/wicg-file-system-access": "^2020.9.4",
"@typescript-eslint/eslint-plugin": "5.7.0",
"@typescript-eslint/parser": "5.7.0",
"@typescript-eslint/eslint-plugin": "5.8.1",
"@typescript-eslint/parser": "5.8.1",
"chai": "^4.3.4",
"chai-as-promised": "^7.1.1",
"chalk": "^4.1.2",
"eslint": "8.5.0",
"eslint": "8.6.0",
"eslint-config-prettier": "8.3.0",
"eslint-import-resolver-typescript": "2.5.0",
"eslint-plugin-import": "2.25.3",
"eslint-plugin-react": "7.27.1",
"eslint-plugin-react": "7.28.0",
"eslint-plugin-react-hooks": "4.3.0",
"fs-extra": "^10.0.0",
"marked": "^4.0.8",
@ -37,7 +38,7 @@
"prettier": "^2.5.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"sass": "^1.45.0",
"sass": "^1.45.2",
"sinon": "^12.0.1",
"sinon-chai": "^3.7.0",
"tslib": "^2.3.1",

View File

@ -16,6 +16,7 @@ export interface EncryptedItem {
tx: integer // Unix seconds
updated: integer // Unix seconds
uuid: string // 32 chars
fave: number
trashed?: boolean
}
@ -58,6 +59,9 @@ export class Item {
}
return this.#details!
}
get fave() {
return this.#data.fave
}
constructor(crypto: Crypto, data: EncryptedItem) {
this.#crypto = crypto

View File

@ -1,6 +1,7 @@
src/third-party-licenses.json
node_modules
.DS_Store
dist
bundle
*.local
*.yml.d.ts
*.yml.d.ts

View File

@ -6,31 +6,38 @@
"license": "GPL-3.0-only",
"description": "OnePassword local vault viewer",
"scripts": {
"dev": "vite",
"dev": "concurrently vite npm:start",
"build": "vite build",
"serve": "vite preview",
"start": "./esbuild.js && NODE_ENV=development electron --enable-transparent-visuals --disable-gpu ./dist/main/index.js",
"bundle": "./scripts/build.sh"
},
"devDependencies": {
"dependencies": {
"@emotion/css": "^11.7.1",
"@emotion/react": "^11.7.1",
"@emotion/styled": "^11.6.0",
"buffer": "^6.0.3",
"path-browserify": "^1.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-icons": "^4.3.1",
"react-idle-timer": "4.6.4"
},
"devDependencies": {
"@babel/core": "^7.16.7",
"@emotion/babel-plugin": "^11.7.2",
"@rollup/plugin-yaml": "^3.1.0",
"@types/react": "^17.0.37",
"@types/react-dom": "^17.0.11",
"@vitejs/plugin-react": "^1.1.3",
"buffer": "^6.0.3",
"@types/babel__core": "^7.1.18",
"concurrently": "^6.5.1",
"electron": "^16.0.5",
"electron-builder": "^22.14.5",
"esbuild": "^0.14.5",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"opvault.js": "*",
"path-browserify": "^1.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-idle-timer": "4.6.4",
"react-icons": "^4.3.1",
"sass": "^1.45.0",
"typescript": "^4.5.4",
"vite": "^2.7.3"

View File

@ -0,0 +1,42 @@
#!/usr/bin/env node
const fs = require("fs")
const { resolve } = require("path")
const root = resolve(__dirname, "../../..")
const packages = [
root,
resolve(root, "packages/web"),
resolve(root, "packages/opvault.js"),
]
const readJSON = path => JSON.parse(fs.readFileSync(path, "utf-8"))
const infoMap = Object.fromEntries(
packages.flatMap(dir => {
const rootPkg = readJSON(resolve(dir, "package.json"))
const dependencies = Object.keys(rootPkg.dependencies || {})
return dependencies.map(dependency => {
const pkgDir = resolve(dir, "node_modules", dependency)
const pkg = readJSON(resolve(pkgDir, "package.json"))
const licenseFile = fs
.readdirSync(pkgDir)
.filter(x => x.toLowerCase().startsWith("license"))
if (licenseFile.length !== 1) {
console.error(fs.readdirSync(pkgDir))
throw new Error(`Cannot determine license file for ${pkg.name}`)
}
return [
pkg.name,
{
name: pkg.name,
author: pkg.author?.name ?? pkg.author,
license: fs.readFileSync(resolve(pkgDir, licenseFile[0]), "utf-8"),
},
]
})
})
)
fs.writeFileSync(
resolve(__dirname, "../src/third-party-licenses.json"),
JSON.stringify(infoMap, null, 2)
)

View File

@ -1,5 +1,7 @@
#!/bin/sh
npx vite build
NODE_ENV=production ./esbuild.js
./scripts/build-i18n-yml-typedef.js
./scripts/build-third-party-license.js
./scripts/build-package-json.js
./node_modules/.bin/electron-builder build

View File

@ -0,0 +1,41 @@
import * as babel from "@babel/core"
import type { PluginOption, TransformResult } from "vite"
const sourceRegex = /\.(j|t)sx?$/
export default function macrosPlugin(): PluginOption {
return {
name: "babel-macros",
enforce: "pre",
transform(source: string, filename: string) {
if (filename.includes("node_modules")) {
return undefined
}
if (!sourceRegex.test(filename)) {
return
}
const hasBabelMacro = source.includes('.macro"')
const hasEmotion = source.includes("@emotion")
if (!hasBabelMacro && !hasEmotion) {
return undefined
}
const result = babel.transformSync(source, {
filename,
parserOpts: {
plugins: ["jsx", "typescript", "decorators-legacy"],
},
plugins: [
hasBabelMacro && require.resolve("babel-plugin-macros"),
hasEmotion && require.resolve("@emotion/babel-plugin"),
].filter(Boolean),
generatorOpts: {
decoratorsBeforeExport: true,
},
babelrc: false,
configFile: false,
sourceMaps: true,
})
return result as TransformResult | null
},
}
}

View File

@ -48,9 +48,5 @@ export const App: React.FC = () => {
)
}
return (
<div>
<VaultView onLock={onLock} vault={vault} />
</div>
)
return <VaultView onLock={onLock} vault={vault} />
}

View File

@ -0,0 +1,65 @@
import styled from "@emotion/styled"
import { useEffect, useMemo, useState } from "react"
import { ClickableContainer } from "../components/ItemFieldValue"
import { scrollbar } from "../styles"
const Container = styled.div`
display: flex;
`
const ListContainer = styled.div`
min-width: 150px;
ul {
list-style-type: none;
margin-block-start: 0;
padding-inline-start: 0;
}
li {
line-height: 1.6em;
}
`
const LicenseText = styled.div`
flex-grow: 1;
font-family: var(--monospace);
max-height: 575px;
overflow-y: scroll;
white-space: pre-wrap;
`
export const LicenseView = () => {
const [licenseInfo, setLicenseInfo] = useState<
typeof import("../third-party-licenses.json")
>(() => ({} as any))
const names = useMemo(() => Object.keys(licenseInfo), [licenseInfo])
const [selected, setSelected] = useState<string>()
useEffect(() => {
import("../third-party-licenses.json").then(json => setLicenseInfo(json.default))
}, [])
useEffect(() => {
setSelected(names[0])
}, [names])
return (
<Container>
<ListContainer>
<ul>
{names.map(name => (
<li
key={name}
style={name === selected ? { fontWeight: 600 } : undefined}
onClick={() => setSelected(name)}
>
<ClickableContainer>{name}</ClickableContainer>
</li>
))}
</ul>
</ListContainer>
<LicenseText className={scrollbar}>
{licenseInfo[selected as any]?.license}
</LicenseText>
</Container>
)
}

View File

@ -0,0 +1,27 @@
import styled from "@emotion/styled"
import { Modal } from "../components/Modal"
import { useTranslate } from "../i18n"
import { LicenseView } from "./LicenseViewer"
const Container = styled.div`
width: 800px;
min-height: 450px;
`
const LicenseSectionHeader = styled.h3`
margin-top: 0;
`
export const About: React.FC<{
show: boolean
onHide(): void
}> = ({ show, onHide }) => {
const t = useTranslate()
return (
<Modal maxWidth={800} show={show} title={t.label.about_app} onClose={onHide}>
<Container>
<LicenseSectionHeader>Licenses</LicenseSectionHeader>
<LicenseView />
</Container>
</Modal>
)
}

View File

@ -0,0 +1,198 @@
import styled from "@emotion/styled"
import { useEffect, useMemo, useState } from "react"
import type { Item } from "opvault.js"
import { Category } from "opvault.js"
import { IoSearch } from "react-icons/io5"
import { ItemList } from "../components/ItemList"
import { ItemView } from "../components/Item"
import { reactIconClass } from "../components/CategoryIcon"
import { useTranslate } from "../i18n/index"
import { scrollbar } from "../styles"
const ListContainer = styled.div`
border-right: 1px solid var(--border-color);
width: 350px;
margin-right: 10px;
overflow-y: scroll;
overflow-y: overlay;
overflow-x: hidden;
@media (prefers-color-scheme: dark) {
background: #202020;
border-right-color: transparent;
}
`
const ItemContainer = styled.div`
width: calc(100% - 300px);
overflow: hidden;
`
const SearchContainer = styled.div`
text-align: center;
position: relative;
flex-grow: 1;
margin: 10px 0;
margin-right: 10px;
`
const SortContainer = styled.div`
display: flex;
margin: 10px 0;
`
const CategorySelect = styled.select`
width: 50%;
margin-left: 10px;
margin-right: 5px;
`
const SortSelect = styled.select`
width: calc(50% - 25px);
`
const SearchInput = styled.input`
--margin: 10px;
width: calc(100% - var(--margin) * 2 + 9px);
margin: 0 var(--margin);
padding-left: 2em !important;
`
const SearchIcon = styled(IoSearch)`
position: absolute;
top: 9px;
left: 20px;
`
const enum SortBy {
Name,
CreatedAt,
UpdatedAt,
}
export const FilteredVaultView: React.FC<{ items: Item[] }> = ({ items }) => {
const t = useTranslate()
const [item, setItem] = useState<Item>()
const [category, setCategory] = useState<Category>()
const [sortBy, setSortBy] = useState(SortBy.Name)
const [search, setSearch] = useState("")
useEffect(() => {
setItem(undefined)
}, [items])
const compareFn = useMemo((): ((a: Item, b: Item) => number) => {
switch (sortBy) {
case SortBy.Name:
return (a, b) => (a.overview.title ?? "").localeCompare(b?.overview.title ?? "")
case SortBy.CreatedAt:
return (a, b) => b.createdAt - a.createdAt
case SortBy.UpdatedAt:
return (a, b) => b.updatedAt - a.updatedAt
}
}, [sortBy])
const sortedItem = useMemo(() => items.slice().sort(compareFn), [items, compareFn])
const filtered = useMemo(() => {
let items = sortedItem.filter(x => x.category !== Category.Tombstone)
if (category != null) {
items = items.filter(x => x.category === category)
}
let res: Item[] = items
if (search) {
res = []
for (const x of items) {
const compare = Math.max(
stringCompare(search, x.overview.title),
stringCompare(search, x.overview.ainfo)
) as CompareResult
switch (compare) {
case CompareResult.NoMatch:
continue
case CompareResult.Includes:
res.push(x)
break
case CompareResult.Equals:
res.unshift(x)
break
}
}
}
return res
}, [sortedItem, search, category])
const categoryMap = useMemo(
(): [Category | undefined, string][] => [
[undefined, t.label.category_all],
[Category.Login, t.label.category_login],
[Category.SecureNote, t.label.category_secure_note],
[Category.CreditCard, t.label.category_credit_card],
[Category.Identity, t.label.category_identity],
[Category.Password, t.label.category_password],
[Category.Membership, t.label.category_membership],
[Category.Database, t.label.category_database],
[Category.BankAccount, t.label.category_bank_account],
[Category.Email, t.label.category_email],
[Category.SoftwareLicense, t.label.category_software_license],
[Category.SSN, t.label.category_ssn],
[Category.Passport, t.label.category_passport],
[Category.OutdoorLicense, t.label.category_outdoor_license],
[Category.DriverLicense, t.label.category_driver_license],
[Category.Rewards, t.label.category_rewards],
[Category.Router, t.label.category_router],
[Category.Server, t.label.category_server],
],
[t]
)
return (
<>
<ListContainer className={scrollbar}>
<SearchContainer>
<SearchInput
type="search"
value={search}
onChange={e => setSearch(e.currentTarget.value)}
/>
<SearchIcon className={reactIconClass} />
</SearchContainer>
<SortContainer>
<CategorySelect
value={category}
onChange={e => setCategory((e.currentTarget.value as Category) || undefined)}
>
{categoryMap.map(([value, name]) => (
<option value={value || ""} key={value}>
{name}
</option>
))}
</CategorySelect>
<SortSelect value={sortBy} onChange={e => setSortBy(+e.currentTarget.value)}>
<option value={SortBy.Name}>{t.options.sort_by_name}</option>
<option value={SortBy.CreatedAt}>{t.options.sort_by_created_at}</option>
<option value={SortBy.UpdatedAt}>{t.options.sort_by_updated_at}</option>
</SortSelect>
</SortContainer>
<ItemList items={filtered} onSelect={setItem} selected={item} />
</ListContainer>
<ItemContainer>
{item && <ItemView className={scrollbar} item={item} />}
</ItemContainer>
</>
)
}
enum CompareResult {
NoMatch,
Includes,
Equals,
}
function stringCompare(search: string, source?: string) {
if (!search) return CompareResult.Includes
if (!source) return CompareResult.NoMatch
source = source.toLocaleLowerCase()
search = search.toLocaleUpperCase()
const includes = source.includes(search.toLocaleLowerCase())
if (includes) {
return source.length === search.length ? CompareResult.Equals : CompareResult.Includes
}
return CompareResult.NoMatch
}

View File

@ -8,6 +8,7 @@ const Container = styled.div`
font-size: 90%;
line-height: 1.5em;
opacity: 0.5;
user-select: none;
`
export const ItemDates = memo<{ item: Item }>(({ item }) => {

View File

@ -4,10 +4,11 @@ import styled from "@emotion/styled"
const Container = styled.menu`
background-color: #fff;
border-radius: 3px;
box-shadow: #0004 0px 1px 4px;
box-shadow: rgb(15 15 15 / 5%) 0px 0px 0px 1px, rgb(15 15 15 / 10%) 0px 3px 6px,
rgb(15 15 15 / 20%) 0px 9px 24px;
left: 99%;
margin-block-start: 0;
min-width: 180px;
min-width: 195px;
padding-inline-start: 0;
position: absolute;
top: 0;
@ -33,17 +34,23 @@ const Separator = styled.div`
const Item = styled.div`
cursor: default;
font-size: 13px;
font-size: 14px;
flex: 1 1 auto;
display: flex;
height: 2.5em;
height: 2.3em;
align-items: center;
padding-left: 1em;
padding-right: 5px;
position: relative;
&:first-of-type {
border-radius: 3px 3px 0 0;
}
&:last-of-type {
border-radius: 0 0 3px 3px;
}
&:hover {
background-color: #ddd;
border-radius: 3px;
.item-field-context-menu {
display: block;
}

View File

@ -17,6 +17,8 @@ const Container = styled.div`
}
`
export { Container as ClickableContainer }
function useCopy(text: string) {
const t = useTranslate()
return useCallback(() => {

View File

@ -2,6 +2,7 @@ import { memo } from "react"
import styled from "@emotion/styled"
import { cx } from "@emotion/css"
import type { Item } from "opvault.js"
import { AiFillStar } from "react-icons/ai"
import { CategoryIcon } from "./CategoryIcon"
import { useTranslate } from "../i18n"
import { ItemNoTitle } from "../styles"
@ -18,13 +19,13 @@ const List = styled.ol`
padding: 0;
`
const ItemView = styled.li`
border-radius: 5px;
display: grid;
padding: 5px 15px;
transition: background-color 0.1s;
align-items: center;
cursor: default;
display: grid;
grid-template-columns: 35px 1fr;
padding: 5px 15px;
position: relative;
transition: background-color 0.1s;
user-select: none;
&:hover {
background-color: var(--hover-background);
@ -47,10 +48,21 @@ const ItemDescription = styled.div`
overflow: hidden;
text-overflow: ellipsis;
max-width: 230px;
&.empty {
opacity: 0.4;
}
`
const Icon = styled(CategoryIcon)`
font-size: 1.5em;
`
const Favorite = styled(AiFillStar)`
bottom: 10px;
display: inline-block;
fill: #fdcc0d;
left: 10px;
opacity: 0.9;
position: absolute;
`
export const ItemList = memo<ListProps>(({ items, onSelect, selected }) => {
const t = useTranslate()
@ -66,12 +78,17 @@ export const ItemList = memo<ListProps>(({ items, onSelect, selected }) => {
trashed: item.isDeleted,
})}
>
<Icon fill="#FFF" category={item.category} />
<div>
<Icon fill="#FFF" category={item.category} />
{!!item.fave && <Favorite />}
</div>
<div>
<ItemTitle>
{item.overview.title || <ItemNoTitle>{t.label.no_title}</ItemNoTitle>}
</ItemTitle>
<ItemDescription>{item.overview.ainfo || " "}</ItemDescription>
<ItemDescription className={cx(!item.overview.ainfo && "empty")}>
{item.overview.ainfo || "-"}
</ItemDescription>
</div>
</ItemView>
))}

View File

@ -25,7 +25,6 @@ const ModalContainer = styled.div`
background: var(--page-background);
box-shadow: rgba(0, 0, 0, 0.25) 0px 14px 28px, rgba(0, 0, 0, 0.22) 0px 10px 10px;
border-radius: 5px;
max-width: 500px;
margin: 0 auto;
`
const ModalTitle = styled.div`
@ -41,8 +40,9 @@ const ModalContent = styled.div`
export const Modal: React.FC<{
show: boolean
title: string
maxWidth?: number
onClose(): void
}> = ({ show, children, title, onClose }) => {
}> = ({ show, children, title, maxWidth = 700, onClose }) => {
const onBackgroundClick = useCallback(
e => {
if (e.currentTarget === e.target) {
@ -61,7 +61,7 @@ export const Modal: React.FC<{
<>
<ModalBackground />
<ModalBackground2 onClick={onBackgroundClick}>
<ModalContainer>
<ModalContainer style={{ maxWidth }}>
<ModalTitle>{title}</ModalTitle>
<ModalContent>{children}</ModalContent>
</ModalContainer>

View File

@ -55,6 +55,106 @@ label:
fr: Langue
ja: 言語
about_app:
en: About
fr: À propos
ja: バーション情報
category_all:
en: All
fr: Tous
ja: すべて
category_login:
en: Login
fr: Connexion
ja: ログイン
category_credit_card:
en: Credit Card
fr: Carte de crédit
ja: クレジットカード
category_secure_note:
en: Secure Note
fr: Note sécurisée
ja: セキュアノート
category_identity:
en: Identity
fr: Identité
ja: 個人情報
category_password:
en: Password
fr: Mot de passe
ja: パスワード
category_tombstone:
en: Tombstone
fr: Corbeille
ja: ゴミ箱
category_software_license:
en: Software License
fr: Licence de logiciel
ja: ソフトウェアライセンス
category_bank_account:
en: BankAccount
fr: Compte bancaire
ja: 銀行口座
category_database:
en: Database
fr: Base de données
ja: データベース
category_driver_license:
en: Driver License
fr: Permis de conduire
ja: 運転免許
category_outdoor_license:
en: Outdoor License
fr: Permis de chasse ou pêche
ja: 遊漁券及び狩猟免許
category_membership:
en: Membership
fr: Adhésion
ja: 会員資格
category_passport:
en: Passport
fr: Passeport
ja: 旅券
category_rewards:
en: Rewards
fr: Programme de fidélité
ja: ポイントサービス
category_ssn:
en: Social Security Numbers
fr: N° de sécurité sociale
ja: 社会保障番号
category_router:
en: Router
fr: Routeur sans fil
ja: Wi-Fiルーター
category_server:
en: Server
fr: Serveur
ja: サーバー
category_email:
en: Email
fr: Courriel
ja: メール
options:
sort_by_name:
en: Sort by Name
@ -133,6 +233,11 @@ action:
fr: Avancer
ja: 次に進む
clear_history:
en: Clear history
fr: Effacer lhistorique
ja: 閲覧履歴を消す
tips:
automatically_lock_after_inactivity:
en: Automatically lock after inactivity

View File

@ -19,10 +19,10 @@ body {
--titlebar-height: 46px;
--titlebar-height: 0px;
--label-background: #ddd;
--selected-background: #c9c9c9;
--selected-background: #d5d5d5;
--hover-background: #ddd;
--border-color: #ddd;
--monospace: D2Coding, source-code-pro, Menlo, Monaco, Consolas, "Courier New",
--border-color: #e3e3e3;
--monospace: D2Coding, "source-code-pro", Menlo, Monaco, Consolas, "Courier New",
monospace;
}
@ -69,7 +69,7 @@ input {
@mixin input {
@include scheme(background-color, #fff, #2d2d2d);
border-radius: 6px;
border: 1px solid;
border: 1px solid #fff;
@include scheme(border-color, #cdc7c2, #1b1b1b);
transition: 0.1s;
&:focus {
@ -83,7 +83,6 @@ input[type="number"],
input[type="password"] {
@include input;
border-radius: 6px;
border: 1px solid;
color: inherit;
outline: none;
padding: 7px 8px;
@ -110,7 +109,8 @@ input[type="checkbox" i] {
}
button,
select {
select,
.button {
@include scheme(background-color, #f6f5f4, #333);
border-radius: 4px;
border: 1px solid;

View File

@ -1,17 +1,13 @@
import styled from "@emotion/styled"
import { useEffect, useMemo, useState } from "react"
import type { Vault, Item } from "opvault.js"
import { Category } from "opvault.js"
import { AiOutlineStar } from "react-icons/ai"
import { FiLock } from "react-icons/fi"
import { IoSearch } from "react-icons/io5"
import { Si1Password } from "react-icons/si"
import { BsGear } from "react-icons/bs"
import { ItemList } from "../components/ItemList"
import { ItemView } from "../components/Item"
import { reactIconClass } from "../components/CategoryIcon"
import { useTranslate } from "../i18n/index"
import { scrollbar } from "../styles"
import { Settings } from "../settings"
import { FilteredVaultView } from "../components/FilteredVaultView"
const Container = styled.div`
display: flex;
@ -21,9 +17,9 @@ const TabContainer = styled.div`
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
width: 55px;
overflow: hidden;
padding-bottom: 5px;
width: 54px;
@media (prefers-color-scheme: dark) {
background: #222;
border-right-color: transparent;
@ -42,6 +38,7 @@ const TabButton = styled.button<{ active?: boolean }>`
margin-bottom: 5px;
font-size: 22px;
padding: 10px 14px;
${p => p.active && "&:hover { background: var(--selected-background); }"}
@media (prefers-color-scheme: dark) {
--selected-background: #1c1c1c;
}
@ -49,111 +46,30 @@ const TabButton = styled.button<{ active?: boolean }>`
const TabContainerMain = styled.div`
flex-grow: 1;
`
const ListContainer = styled.div`
border-right: 1px solid var(--border-color);
width: 350px;
margin-right: 10px;
overflow-y: scroll;
overflow-x: hidden;
@media (prefers-color-scheme: dark) {
background: #202020;
border-right-color: transparent;
}
`
const ItemContainer = styled.div`
width: calc(100% - 300px);
overflow: hidden;
`
const SearchContainer = styled.div`
text-align: center;
position: relative;
flex-grow: 1;
margin: 10px 0;
margin-right: 10px;
`
const SortContainer = styled.div`
margin: 10px 10px;
`
const SearchInput = styled.input`
--margin: 10px;
width: calc(100% - var(--margin) * 2 + 9px);
margin: 0 var(--margin);
padding-left: 2em !important;
`
const SearchIcon = styled(IoSearch)`
position: absolute;
top: 9px;
left: 20px;
`
const enum SortBy {
Name,
CreatedAt,
UpdatedAt,
}
export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
vault,
onLock,
}) => {
const [tab, setTab] = useState(Tab.All)
const [items, setItems] = useState<Item[]>(() => [])
const [showSettings, setShowSettings] = useState(false)
const t = useTranslate()
const [items, setItems] = useState<Item[]>(() => [])
const [item, setItem] = useState<Item>()
const [sortBy, setSortBy] = useState(SortBy.Name)
const [search, setSearch] = useState("")
const compareFn = useMemo((): ((a: Item, b: Item) => number) => {
switch (sortBy) {
case SortBy.Name:
return (a, b) => (a.overview.title ?? "").localeCompare(b?.overview.title ?? "")
case SortBy.CreatedAt:
return (a, b) => b.createdAt - a.createdAt
case SortBy.UpdatedAt:
return (a, b) => b.updatedAt - a.updatedAt
}
}, [sortBy])
const sortedItem = useMemo(() => items.slice().sort(compareFn), [items, compareFn])
useEffect(() => {
setItem(undefined)
arrayFrom(vault.values()).then(setItems)
}, [vault])
const filtered = useMemo(() => {
const items = sortedItem.filter(x => x.category !== Category.Tombstone)
let res: Item[] = items
if (search) {
res = []
for (const x of items) {
const compare = Math.max(
stringCompare(search, x.overview.title),
stringCompare(search, x.overview.ainfo)
) as CompareResult
switch (compare) {
case CompareResult.NoMatch:
continue
case CompareResult.Includes:
res.push(x)
break
case CompareResult.Equals:
res.unshift(x)
break
}
}
}
return res
}, [sortedItem, search])
return (
<Container>
<TabContainer>
<TabContainerMain>
<TabButton active>
<TabButton active={tab === Tab.All} onClick={() => setTab(Tab.All)}>
<Si1Password />
</TabButton>
<TabButton active={tab === Tab.Favorites} onClick={() => setTab(Tab.Favorites)}>
<AiOutlineStar />
</TabButton>
</TabContainerMain>
<TabButton onClick={onLock} title={t.action.lock}>
<FiLock />
@ -163,38 +79,25 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
</TabButton>
</TabContainer>
<ListContainer className={scrollbar}>
<SearchContainer>
<SearchInput
type="search"
value={search}
onChange={e => setSearch(e.currentTarget.value)}
/>
<SearchIcon className={reactIconClass} />
</SearchContainer>
<SortContainer>
<select
style={{ width: "100%" }}
value={sortBy}
onChange={e => setSortBy(+e.currentTarget.value)}
>
<option value={SortBy.Name}>{t.options.sort_by_name}</option>
<option value={SortBy.CreatedAt}>{t.options.sort_by_created_at}</option>
<option value={SortBy.UpdatedAt}>{t.options.sort_by_updated_at}</option>
</select>
</SortContainer>
<ItemList items={filtered} onSelect={setItem} selected={item} />
</ListContainer>
<ItemContainer>
{item && <ItemView className={scrollbar} item={item} />}
</ItemContainer>
{tab === Tab.All ? (
<FilteredVaultView items={items} />
) : tab === Tab.Favorites ? (
<FavoriteItemsView items={items} />
) : null}
<Settings show={showSettings} onHide={() => setShowSettings(false)} />
</Container>
)
}
const FavoriteItemsView: React.FC<{ items: Item[] }> = ({ items }) => {
const favorites = useMemo(
() => items.filter(x => x.fave).sort((a, b) => a.fave - b.fave),
[items]
)
return <FilteredVaultView items={favorites} />
}
async function arrayFrom<T>(generator: AsyncGenerator<T, void, unknown>) {
const list: T[] = []
for await (const value of generator) {
@ -203,20 +106,7 @@ async function arrayFrom<T>(generator: AsyncGenerator<T, void, unknown>) {
return list
}
enum CompareResult {
NoMatch,
Includes,
Equals,
}
function stringCompare(search: string, source?: string) {
if (!search) return CompareResult.Includes
if (!source) return CompareResult.NoMatch
source = source.toLocaleLowerCase()
search = search.toLocaleUpperCase()
const includes = source.includes(search.toLocaleLowerCase())
if (includes) {
return source.length === search.length ? CompareResult.Equals : CompareResult.Includes
}
return CompareResult.NoMatch
enum Tab {
All,
Favorites,
}

View File

@ -0,0 +1,148 @@
import styled from "@emotion/styled"
import { css } from "@emotion/css"
import { useCallback, useMemo, memo, useState } from "react"
import { Si1Password } from "react-icons/si"
import { FaFolderOpen } from "react-icons/fa"
import { ImCross } from "react-icons/im"
import { MdClearAll } from "react-icons/md"
import { BsGear, BsInfoCircle } from "react-icons/bs"
import { openDirectory } from "../../utils/ipc-adapter"
import { useTranslate } from "../../i18n"
import { Key, useStorage } from "../../utils/localStorage"
import { Settings } from "../../settings"
import { About } from "../../about"
const Container = styled.div`
padding: 100px;
max-width: 600px;
margin: 0 auto;
`
const List = styled.ul`
list-style-type: none;
padding-inline-start: 0;
`
const Item = styled.li`
align-items: center;
cursor: default;
display: flex;
padding: 8px 10px;
user-select: none;
&:not(:hover):not(:active) {
background: transparent;
border-color: transparent;
}
`
const icon = css`
font-size: 1.5em;
margin-right: 10px;
`
const Text = styled.div`
flex-grow: 1;
`
const Hr = styled.hr`
border: none;
border-top: 1px solid var(--border-color);
`
const DeleteItem = styled(ImCross)`
text-align: right;
font-size: 0.7em;
opacity: 0;
${Item}:hover & {
opacity: 1;
}
`
const NonCriticalPath = styled.span`
opacity: 0.4;
`
const Path = memo(({ children }: { children: string }) => {
const segments = useMemo(() => children.split("/"), [children])
return (
<span>
{segments.map((seg, i, { length }) =>
i < length - 1 ? (
<NonCriticalPath key={i}>{seg}/</NonCriticalPath>
) : (
<span key={i}>{seg}</span>
)
)}
</span>
)
})
const enum Modal {
None,
Settings,
About,
}
export const PickOPVault: React.FC<{
setPath(path: string): void
}> = ({ setPath }) => {
const t = useTranslate()
const [modal, setModal] = useState(Modal.None)
const [list, $setList] = useStorage(Key.RECENTLY_OPENED_VAULTS)
const clearHistory = useCallback(() => {
$setList([])
}, [$setList])
const setList = useCallback(
(fn: (value: Set<string>) => void) => {
$setList(list => {
const set = new Set(list)
fn(set)
return Array.from(set)
})
},
[$setList]
)
const onClick = useCallback(async () => {
const path = await openDirectory()
if (path) {
setPath(path)
setList(set => set.add(path))
}
}, [setPath, setList])
return (
<Container>
<List>
<Item className="button" onClick={onClick}>
<FaFolderOpen className={icon} />
{t.label.choose_a_vault}
</Item>
{list.map((item, i) => (
<Item className="button" onClick={() => setPath(item)} key={i}>
<Si1Password className={icon} />
<Text>
<Path>{item}</Path>
</Text>
<DeleteItem onClick={() => setList(list => list.delete(item))} />
</Item>
))}
{list.length > 0 && (
<>
<Hr />
<Item className="button" onClick={() => clearHistory()}>
<MdClearAll className={icon} />
{t.action.clear_history}
</Item>
</>
)}
<Item className="button" onClick={() => setModal(Modal.Settings)}>
<BsGear className={icon} />
{t.label.settings}
</Item>
<Item className="button" onClick={() => setModal(Modal.About)}>
<BsInfoCircle className={icon} />
{t.label.about_app}
</Item>
</List>
<Settings show={modal === Modal.Settings} onHide={() => setModal(Modal.None)} />
<About show={modal === Modal.About} onHide={() => setModal(Modal.None)} />
</Container>
)
}

View File

@ -3,7 +3,7 @@ import styled from "@emotion/styled"
import React, { useCallback, useEffect, useState } from "react"
import { IoMdArrowRoundBack } from "react-icons/io"
import { FaUnlock } from "react-icons/fa"
import { useTranslate } from "../i18n"
import { useTranslate } from "../../i18n"
const Container = styled.div`
padding: 20px;
@ -61,12 +61,20 @@ const Submit = styled.button`
top: 8px;
right: 5px;
`
const VaultPath = styled.div`
margin-top: 15px;
opacity: 0.7;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`
export const Unlock: React.FC<{
instance: OnePassword
vaultPath: string
onUnlock(profile: string, password: string): void
onReturn(): void
}> = ({ onUnlock, onReturn, instance }) => {
}> = ({ onUnlock, onReturn, instance, vaultPath }) => {
const t = useTranslate()
const [profiles, setProfiles] = useState<string[]>(() => [])
const [profile, setProfile] = useState<string>()
@ -134,6 +142,7 @@ export const Unlock: React.FC<{
<FaUnlock />
</Submit>
</div>
<VaultPath>{vaultPath}</VaultPath>
</Container>
)
}

View File

@ -1,11 +1,10 @@
import styled from "@emotion/styled"
import { useCallback, useEffect, useState } from "react"
import type { Vault } from "opvault.js"
import { OnePassword } from "opvault.js"
import { Unlock } from "./Unlock"
import { electronAdapter, openDirectory } from "../utils/ipc-adapter"
import { get, remove, set, Key } from "../utils/localStorage"
import { useTranslate } from "../i18n"
import { electronAdapter } from "../../utils/ipc-adapter"
import { get, remove, set, Key } from "../../utils/localStorage"
import { PickOPVault } from "./Picker"
interface VaultPickerProps {
instance: OnePassword | undefined
@ -28,13 +27,13 @@ export const VaultPicker: React.FC<VaultPickerProps> = ({
await vault.unlock(password)
setVault(vault)
},
[instance]
[instance, setVault]
)
const clearInstance = useCallback(() => {
setVaultPath("")
setInstance(undefined)
}, [])
}, [setInstance])
useEffect(() => {
const existingPath = get(Key.LAST_VAULT_PATH)
@ -55,40 +54,20 @@ export const VaultPicker: React.FC<VaultPickerProps> = ({
setInstance(undefined)
remove(Key.LAST_VAULT_PATH)
}
}, [vaultPath])
}, [vaultPath, setInstance])
if (!instance) {
return <PickOPVault setPath={setVaultPath} />
}
if (!vault) {
return <Unlock onReturn={clearInstance} instance={instance} onUnlock={unlock} />
return (
<Unlock
vaultPath={vaultPath}
onReturn={clearInstance}
instance={instance}
onUnlock={unlock}
/>
)
}
return null
}
const PickOPVaultContainer = styled.div`
padding: 100px;
text-align: center;
`
const PickOPVaultInfo = styled.div`
margin: 10px;
`
const PickOPVault: React.FC<{
setPath(path: string): void
}> = ({ setPath }) => {
const t = useTranslate()
const onClick = useCallback(async () => {
const path = await openDirectory()
if (path) {
setPath(path)
}
}, [setPath])
return (
<PickOPVaultContainer>
<button onClick={onClick}>{t.label.choose_a_vault}</button>
<PickOPVaultInfo>{t.label.no_vault_selected}</PickOPVaultInfo>
</PickOPVaultContainer>
)
}

View File

@ -36,7 +36,7 @@ const GhostLabel = styled.div`
export const Settings: React.FC<{
show: boolean
onHide(): void
}> = ({ show = true, onHide }) => {
}> = ({ show, onHide }) => {
const { locale, setLocale } = useLocaleContext()
const t = useTranslate()

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"
export enum Key {
LAST_VAULT_PATH = "app.state.last_vault_path",
RECENTLY_OPENED_VAULTS = "app.state.recently_opened_vaults",
PREFERRED_LOCALE = "app.config.locale",
ENABLE_AUTO_LOCK = "app.config.enable_auto_lock",
AUTO_LOCK_AFTER = "app.config.auto_lock_after",
@ -9,6 +10,7 @@ export enum Key {
interface StoredData {
[Key.LAST_VAULT_PATH]: string
[Key.RECENTLY_OPENED_VAULTS]: string[]
[Key.PREFERRED_LOCALE]: string
[Key.ENABLE_AUTO_LOCK]: boolean
[Key.AUTO_LOCK_AFTER]: number
@ -27,7 +29,7 @@ export function useStorage<K extends Key>(key: K) {
}
}, [key])
const setState2 = useCallback(
(value: StoredData[K]) => {
(value: ((value: StoredData[K]) => StoredData[K]) | StoredData[K]) => {
set(key, value)
},
[key]
@ -43,10 +45,16 @@ export function get<K extends Key>(key: K): StoredData[K] | undefined {
} catch {}
}
export function set<K extends Key>(key: K, value: StoredData[K]) {
export function set<K extends Key>(
key: K,
value: ((value: StoredData[K]) => StoredData[K]) | StoredData[K]
) {
try {
if (typeof value === "function") {
value = value(get(key)!)
}
localStorage.setItem(key, JSON.stringify(value))
events.get(key).forEach(fn => fn(value))
events.get(key).forEach(fn => fn(value as StoredData[K]))
} catch (e) {
console.error(e)
}
@ -64,4 +72,5 @@ const defaults: typeof set = (key, value) => {
}
}
defaults(Key.ENABLE_AUTO_LOCK, true)
defaults(Key.AUTO_LOCK_AFTER, 120)
defaults(Key.AUTO_LOCK_AFTER, 180)
defaults(Key.RECENTLY_OPENED_VAULTS, [])

View File

@ -1 +1,5 @@
/// <reference types="vite/client" />
interface Array<T> {
filter(predicate: BooleanConstructor): Exclude<T, null | undefined | 0 | "" | false>[]
}

View File

@ -1,11 +1,12 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import yaml from "@rollup/plugin-yaml"
import babel from "./scripts/vite-babel"
// https://vitejs.dev/config/
export default defineConfig({
base: "./",
plugins: [react(), yaml()],
plugins: [babel(), react(), yaml()],
define: {
global: "globalThis",
"process.browser": "true",

670
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff