Migrate to native Node.js file access and improve UI
This commit is contained in:
parent
06e29eaba1
commit
eb27e81d68
@ -8,7 +8,7 @@ const args = process.argv.slice(2)
|
||||
build({
|
||||
bundle: true,
|
||||
define: {},
|
||||
entryPoints: ["./src/electron/index.ts"],
|
||||
entryPoints: ["./src/electron/index.ts", "./src/electron/preload.ts"],
|
||||
outdir: "./dist/main",
|
||||
external: builtinModules.concat("electron"),
|
||||
target: ["chrome90"],
|
||||
|
@ -11,7 +11,7 @@
|
||||
<title>OPVault Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,40 +1,26 @@
|
||||
import { useCallback, useState } from "react"
|
||||
import type { Vault } from "opvault.js"
|
||||
import { OnePassword } from "opvault.js"
|
||||
import { getBrowserAdapter } from "opvault.js/src/adapters/browser"
|
||||
import type { Vault, OnePassword } from "opvault.js"
|
||||
import { VaultView } from "./pages/Vault"
|
||||
import { PickOPVault } from "./pages/PickOPVault"
|
||||
import { Unlock } from "./pages/Unlock"
|
||||
import { VaultPicker } from "./pages/VaultPicker"
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [instance, setInstance] = useState<OnePassword>()
|
||||
const [vault, setVault] = useState<Vault>()
|
||||
|
||||
const unlock = useCallback(
|
||||
async (profile: string, password: string) => {
|
||||
const vault = await instance!.getProfile(profile!)
|
||||
await vault.unlock(password)
|
||||
setVault(vault)
|
||||
},
|
||||
[instance]
|
||||
)
|
||||
|
||||
const setHandle = useCallback(async (handle: FileSystemDirectoryHandle) => {
|
||||
const adapter = getBrowserAdapter(handle)
|
||||
const instance = new OnePassword({ path: "/", adapter })
|
||||
setInstance(instance)
|
||||
}, [])
|
||||
|
||||
const onLock = useCallback(() => {
|
||||
vault?.lock()
|
||||
setVault(undefined)
|
||||
}, [vault])
|
||||
|
||||
if (!instance) {
|
||||
return <PickOPVault setHandle={setHandle} />
|
||||
}
|
||||
if (!vault) {
|
||||
return <Unlock instance={instance} onUnlock={unlock} />
|
||||
return (
|
||||
<VaultPicker
|
||||
instance={instance}
|
||||
setInstance={setInstance}
|
||||
vault={vault}
|
||||
setVault={setVault}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -1,6 +1,7 @@
|
||||
import styled from "@emotion/styled"
|
||||
import type { Attachment, AttachmentMetadata, Item } from "opvault.js"
|
||||
import type { Attachment, AttachmentMetadata, Item, ItemField } from "opvault.js"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslate } from "../i18n"
|
||||
import { CategoryIcon } from "./CategoryIcon"
|
||||
import { ItemDates } from "./ItemDates"
|
||||
import {
|
||||
@ -13,6 +14,7 @@ import { ItemWarning } from "./ItemWarning"
|
||||
|
||||
interface ItemViewProps {
|
||||
item: Item
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Header = styled.div`
|
||||
@ -55,8 +57,32 @@ const AttachmentContainer = styled.div`
|
||||
margin: 5px 0;
|
||||
`
|
||||
|
||||
export const ItemView: React.FC<ItemViewProps> = ({ item }) => (
|
||||
<Container>
|
||||
const FieldsView: React.FC<{ fields?: ItemField[] }> = ({ fields }) =>
|
||||
fields?.length ? (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
{fields.map((field, i) => (
|
||||
<ItemDetailsFieldView key={i} field={field} />
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const TagsView: React.FC<{ tags?: string[] }> = ({ tags }) => {
|
||||
const t = useTranslate()
|
||||
if (!tags?.length) return null
|
||||
return (
|
||||
<ExtraField>
|
||||
<FieldTitle>{t.noun_tags}</FieldTitle>
|
||||
<div>
|
||||
{tags.map((tag, i) => (
|
||||
<Tag key={i}>{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</ExtraField>
|
||||
)
|
||||
}
|
||||
|
||||
export const ItemView: React.FC<ItemViewProps> = ({ className, item }) => (
|
||||
<Container className={className}>
|
||||
<Inner>
|
||||
<ItemWarning item={item} />
|
||||
<Header>
|
||||
@ -84,13 +110,7 @@ export const ItemView: React.FC<ItemViewProps> = ({ item }) => (
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!!item.details.fields?.length && (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
{item.details.fields!.map((field, i) => (
|
||||
<ItemDetailsFieldView key={i} field={field} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FieldsView fields={item.details.fields} />
|
||||
|
||||
{item.details.notesPlain != null && (
|
||||
<ExtraField>
|
||||
@ -101,16 +121,7 @@ export const ItemView: React.FC<ItemViewProps> = ({ item }) => (
|
||||
</ExtraField>
|
||||
)}
|
||||
|
||||
{!!item.overview.tags?.length && (
|
||||
<ExtraField>
|
||||
<FieldTitle>tags</FieldTitle>
|
||||
<div>
|
||||
{item.overview.tags!.map((tag, i) => (
|
||||
<Tag key={i}>{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</ExtraField>
|
||||
)}
|
||||
<TagsView tags={item.overview.tags} />
|
||||
|
||||
{item.attachments.length > 0 && (
|
||||
<ExtraField>
|
||||
|
@ -62,7 +62,7 @@ export const ItemList: React.FC<ListProps> = ({ items, onSelect, selected }) =>
|
||||
<Icon fill="#FFF" category={item.category} />
|
||||
<div>
|
||||
<ItemTitle>{item.overview.title!}</ItemTitle>
|
||||
<ItemDescription>{item.overview.ainfo}</ItemDescription>
|
||||
<ItemDescription>{item.overview.ainfo || " "}</ItemDescription>
|
||||
</div>
|
||||
</ItemView>
|
||||
))}
|
||||
|
@ -1,22 +0,0 @@
|
||||
import { useCallback } from "react"
|
||||
import { useTranslate } from "../i18n"
|
||||
|
||||
export const VaultPicker: React.FC<{
|
||||
setHandle(handle: FileSystemDirectoryHandle): void
|
||||
}> = ({ setHandle }) => {
|
||||
const t = useTranslate()
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
try {
|
||||
const handle = await showDirectoryPicker()
|
||||
setHandle(handle)
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError") {
|
||||
return
|
||||
}
|
||||
alert(e)
|
||||
}
|
||||
}, [setHandle])
|
||||
|
||||
return <button onClick={onClick}>{t.label_choose_a_vault}</button>
|
||||
}
|
@ -2,6 +2,7 @@
|
||||
// Modules to control application life and create native browser window
|
||||
import { join } from "path"
|
||||
import { app, BrowserWindow, Menu } from "electron"
|
||||
import "./ipc"
|
||||
|
||||
function createWindow() {
|
||||
// Create the browser window.
|
||||
@ -13,7 +14,7 @@ function createWindow() {
|
||||
icon: join(__dirname, "../512x512.png"),
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
// preload: join(__dirname, "preload.js"),
|
||||
preload: join(__dirname, "preload.js"),
|
||||
},
|
||||
})
|
||||
|
||||
|
9
packages/web/src/electron/ipc-types.d.ts
vendored
Normal file
9
packages/web/src/electron/ipc-types.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
export interface IPC {
|
||||
showDirectoryPicker(): Promise<string | undefined>
|
||||
pathExists(path: string): Promise<boolean>
|
||||
readdir(path: string): Promise<string[]>
|
||||
readBuffer(path: string): Promise<Uint8Array>
|
||||
readFile(path: string): Promise<string>
|
||||
writeFile(path: string, data: string): Promise<void>
|
||||
isDirectory(path: string): Promise<boolean>
|
||||
}
|
53
packages/web/src/electron/ipc.ts
Normal file
53
packages/web/src/electron/ipc.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import fs, { promises } from "fs"
|
||||
import { ipcMain, dialog } from "electron"
|
||||
import type { IPC } from "./ipc-types"
|
||||
|
||||
registerService({
|
||||
async showDirectoryPicker() {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", "treatPackageAsDirectory"],
|
||||
})
|
||||
if (result.canceled || !result.filePaths.length) return
|
||||
return result.filePaths[0]
|
||||
},
|
||||
|
||||
async pathExists(_, path) {
|
||||
return fs.existsSync(path)
|
||||
},
|
||||
|
||||
async readBuffer(_, path) {
|
||||
return promises.readFile(path)
|
||||
},
|
||||
|
||||
async readFile(_, path) {
|
||||
return promises.readFile(path, "utf-8")
|
||||
},
|
||||
|
||||
async writeFile(_, path, content) {
|
||||
await promises.writeFile(path, content)
|
||||
},
|
||||
|
||||
async readdir(_, path) {
|
||||
return promises.readdir(path)
|
||||
},
|
||||
|
||||
async isDirectory(_, path) {
|
||||
const stats = await promises.stat(path)
|
||||
return stats.isDirectory()
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Listens to `channel`, when a new message arrives `listener` would be called
|
||||
* with `listener(event, ...args)`
|
||||
*/
|
||||
function registerService(listeners: {
|
||||
[K in keyof IPC]: (
|
||||
event: Electron.IpcMainEvent,
|
||||
...args: Parameters<IPC[K]>
|
||||
) => ReturnType<IPC[K]>
|
||||
}) {
|
||||
for (const [key, value] of Object.entries(listeners)) {
|
||||
ipcMain.handle(`service-${key}`, value as any)
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { contextBridge } from "electron"
|
||||
import { nodeAdapter } from "opvault.js/src/adapters/node"
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
|
||||
contextBridge.exposeInMainWorld("nodeAdapter", nodeAdapter)
|
||||
contextBridge.exposeInMainWorld("ipcRenderer", {
|
||||
invoke: ipcRenderer.invoke,
|
||||
})
|
||||
|
@ -1,7 +1,7 @@
|
||||
# /* spellchecker: disable */
|
||||
label_choose_a_vault:
|
||||
en: Pick a vault here.
|
||||
fr: Choisir un coffre ici.
|
||||
en: Pick a vault
|
||||
fr: Choisir un coffre
|
||||
|
||||
label_no_vault_selected:
|
||||
en: No vault is selected.
|
||||
@ -15,10 +15,18 @@ label_created_at:
|
||||
en: Created At
|
||||
fr: Créé
|
||||
|
||||
label_password_placeholder:
|
||||
en: Master Password
|
||||
fr: Mot de passe principal
|
||||
|
||||
noun_vault:
|
||||
en: vault
|
||||
fr: coffre
|
||||
|
||||
noun_tags:
|
||||
en: tags
|
||||
fr: mots-clés
|
||||
|
||||
action_lock:
|
||||
en: Lock
|
||||
fr: Vérouiller
|
||||
@ -26,3 +34,7 @@ action_lock:
|
||||
action_unlock:
|
||||
en: Unlock
|
||||
fr: Déverouiller
|
||||
|
||||
action_go_back:
|
||||
en: Back
|
||||
fr: Revenir
|
||||
|
@ -12,34 +12,48 @@ body {
|
||||
font-size: 15px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen", "Ubuntu",
|
||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
|
||||
}
|
||||
:root {
|
||||
--page-background: #fff;
|
||||
--color: #000;
|
||||
--titlebar-height: 46px;
|
||||
--titlebar-height: 0px;
|
||||
--label-background: #ddd;
|
||||
--selected-background: #c9c9c9;
|
||||
--hover-background: #ddd;
|
||||
--border-color: #ddd;
|
||||
--monospace: D2Coding, source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root.mac {
|
||||
--page-background: #f7f7f7;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
background-color: var(--page-background);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
color: #fff;
|
||||
:root {
|
||||
--color: #fff;
|
||||
--label-background: #353535;
|
||||
--selected-background: #353535;
|
||||
--selected-background: #15539e;
|
||||
--border-color: #333;
|
||||
--hover-background: #222;
|
||||
--page-background: #292929;
|
||||
}
|
||||
#root {
|
||||
background-color: #292929;
|
||||
body {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,18 +110,3 @@ button[type="submit"] {
|
||||
select {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@include scheme(background, #8883, #6663);
|
||||
border-radius: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
transition: 0.1s;
|
||||
@include scheme(background, #ddd, #555);
|
||||
}
|
||||
|
@ -4,6 +4,10 @@ import { App } from "./App"
|
||||
import { LocaleContextProvider } from "./i18n"
|
||||
import "./index.scss"
|
||||
|
||||
if (navigator.platform === "MacIntel") {
|
||||
document.documentElement.classList.add("mac")
|
||||
}
|
||||
|
||||
render(
|
||||
<React.StrictMode>
|
||||
{/* <TitleBar /> */}
|
||||
@ -11,5 +15,5 @@ render(
|
||||
<App />
|
||||
</LocaleContextProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById("root")
|
||||
document.getElementById("app")
|
||||
)
|
||||
|
@ -1,23 +0,0 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { VaultPicker } from "../components/VaultPicker"
|
||||
import { useTranslate } from "../i18n"
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 100px;
|
||||
text-align: center;
|
||||
`
|
||||
const Info = styled.div`
|
||||
margin: 10px;
|
||||
`
|
||||
|
||||
export const PickOPVault: React.FC<{
|
||||
setHandle(handle: FileSystemDirectoryHandle): void
|
||||
}> = ({ setHandle }) => {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<Container>
|
||||
<VaultPicker setHandle={setHandle} />
|
||||
<Info>{t.label_no_vault_selected}</Info>
|
||||
</Container>
|
||||
)
|
||||
}
|
@ -1,25 +1,80 @@
|
||||
import type { OnePassword } from "opvault.js"
|
||||
import styled from "@emotion/styled"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import { IoMdArrowRoundBack } from "react-icons/io"
|
||||
import { FaUnlock } from "react-icons/fa"
|
||||
import { useTranslate } from "../i18n"
|
||||
|
||||
const Container = styled.form`
|
||||
const Container = styled.div`
|
||||
padding: 20px;
|
||||
transform: translate(-50%, -50%);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 500px;
|
||||
`
|
||||
const BackButton = styled.button`
|
||||
&& {
|
||||
background: transparent;
|
||||
}
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
font-size: 2em;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
&:hover {
|
||||
svg path {
|
||||
fill: var(--selected-background);
|
||||
transition: 0.2s;
|
||||
}
|
||||
}
|
||||
`
|
||||
const Input = styled.input`
|
||||
box-shadow: inset 0 2px 2px rgb(0 0 0 / 8%);
|
||||
font-size: 1.5em;
|
||||
width: calc(95.5% - 60px);
|
||||
&& {
|
||||
border-radius: 10px;
|
||||
border-width: 1px;
|
||||
padding: 15px 20px;
|
||||
padding-right: 60px;
|
||||
}
|
||||
`
|
||||
const Select = styled.select`
|
||||
float: right;
|
||||
`
|
||||
const Submit = styled.button`
|
||||
font-size: 1.8em;
|
||||
&& {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
svg path {
|
||||
fill: var(--color);
|
||||
}
|
||||
&:hover {
|
||||
transition: 0.2s;
|
||||
}
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 5px;
|
||||
`
|
||||
|
||||
export const Unlock: React.FC<{
|
||||
instance: OnePassword
|
||||
onUnlock(profile: string, password: string): void
|
||||
}> = ({ onUnlock, instance }) => {
|
||||
onReturn(): void
|
||||
}> = ({ onUnlock, onReturn, instance }) => {
|
||||
const t = useTranslate()
|
||||
const [profiles, setProfiles] = useState<string[]>(() => [])
|
||||
const [profile, setProfile] = useState<string>()
|
||||
const [password, setPassword] = useState("")
|
||||
|
||||
const unlock = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
(e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
|
||||
if (!profile) return
|
||||
onUnlock(profile, password)
|
||||
@ -28,6 +83,15 @@ export const Unlock: React.FC<{
|
||||
[onUnlock, profile, password]
|
||||
)
|
||||
|
||||
const onKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
unlock()
|
||||
}
|
||||
},
|
||||
[unlock]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
instance.getProfileNames().then(profiles => {
|
||||
setProfiles(profiles)
|
||||
@ -36,26 +100,40 @@ export const Unlock: React.FC<{
|
||||
}, [instance])
|
||||
|
||||
return (
|
||||
<Container onSubmit={unlock}>
|
||||
<div>
|
||||
<select value={profile} onChange={e => setProfile(e.currentTarget.value)}>
|
||||
<Container>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<BackButton onClick={onReturn} title={t.action_go_back}>
|
||||
<IoMdArrowRoundBack />
|
||||
</BackButton>
|
||||
<Select
|
||||
title={t.noun_vault}
|
||||
value={profile}
|
||||
onChange={e => setProfile(e.currentTarget.value)}
|
||||
>
|
||||
{profiles.map(p => (
|
||||
<option key={p} value={p}>
|
||||
{t.noun_vault}: {p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
<div style={{ margin: "10px 0" }}>
|
||||
<input
|
||||
<div style={{ margin: "10px 0", position: "relative" }}>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.currentTarget.value)}
|
||||
placeholder={t.label_password_placeholder}
|
||||
onKeyUp={onKeyUp}
|
||||
/>
|
||||
<Submit
|
||||
type="submit"
|
||||
disabled={!profile || !password}
|
||||
onClick={unlock}
|
||||
title={t.action_unlock}
|
||||
>
|
||||
<FaUnlock />
|
||||
</Submit>
|
||||
</div>
|
||||
<button type="submit" disabled={!profile || !password}>
|
||||
{t.action_unlock}
|
||||
</button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
@ -1,24 +1,28 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { Vault, Item } from "opvault.js"
|
||||
import { Category } from "opvault.js"
|
||||
import { FiLock } from "react-icons/fi"
|
||||
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 Container = styled.div`
|
||||
display: flex;
|
||||
height: calc(100vh - var(--titlebar-height));
|
||||
`
|
||||
const ListContainer = styled.div`
|
||||
border-right: 1px solid var(--border-color);
|
||||
width: 300px;
|
||||
margin-right: 10px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #202020;
|
||||
border-right-color: transparent;
|
||||
}
|
||||
`
|
||||
const ItemContainer = styled.div`
|
||||
@ -27,15 +31,20 @@ const ItemContainer = styled.div`
|
||||
`
|
||||
const SearchContainer = styled.div`
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
`
|
||||
const SortContainer = styled.div`
|
||||
margin: 10px 10px;
|
||||
`
|
||||
const LockButton = styled.button`
|
||||
&& {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
`
|
||||
const SearchInput = styled.input`
|
||||
--margin: 10px;
|
||||
width: calc(100% - var(--margin) * 2);
|
||||
width: calc(100% - var(--margin) * 2 + 9px);
|
||||
margin: 0 var(--margin);
|
||||
padding-left: 2em !important;
|
||||
`
|
||||
@ -66,9 +75,9 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
||||
case SortBy.Name:
|
||||
return (a, b) => (a.overview.title ?? "").localeCompare(b?.overview.title ?? "")
|
||||
case SortBy.CreatedAt:
|
||||
return (a, b) => a.createdAt - b.createdAt
|
||||
return (a, b) => b.createdAt - a.createdAt
|
||||
case SortBy.UpdatedAt:
|
||||
return (a, b) => a.updatedAt - b.updatedAt
|
||||
return (a, b) => b.updatedAt - a.updatedAt
|
||||
}
|
||||
}, [sortBy])
|
||||
|
||||
@ -95,22 +104,21 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ListContainer>
|
||||
<div
|
||||
style={{
|
||||
margin: "10px 10px",
|
||||
}}
|
||||
>
|
||||
<button onClick={onLock}>{t.action_lock}</button>
|
||||
<ListContainer className={scrollbar}>
|
||||
<div style={{ margin: "10px 10px", display: "flex" }}>
|
||||
<LockButton onClick={onLock} title={t.action_lock}>
|
||||
<FiLock />
|
||||
</LockButton>
|
||||
<SearchContainer>
|
||||
<SearchInput
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<SearchIcon className={reactIconClass} />
|
||||
</SearchContainer>
|
||||
</div>
|
||||
<SearchContainer>
|
||||
<SearchInput
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<SearchIcon className={reactIconClass} />
|
||||
</SearchContainer>
|
||||
|
||||
<SortContainer>
|
||||
<select
|
||||
style={{ width: "100%" }}
|
||||
@ -124,7 +132,9 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
||||
</SortContainer>
|
||||
<ItemList items={filtered} onSelect={setItem} selected={item} />
|
||||
</ListContainer>
|
||||
<ItemContainer>{item && <ItemView item={item} />}</ItemContainer>
|
||||
<ItemContainer>
|
||||
{item && <ItemView className={scrollbar} item={item} />}
|
||||
</ItemContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
94
packages/web/src/pages/VaultPicker.tsx
Normal file
94
packages/web/src/pages/VaultPicker.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
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"
|
||||
|
||||
interface VaultPickerProps {
|
||||
instance: OnePassword | undefined
|
||||
setInstance(value?: OnePassword): void
|
||||
vault: Vault | undefined
|
||||
setVault(vault?: Vault): void
|
||||
}
|
||||
|
||||
export const VaultPicker: React.FC<VaultPickerProps> = ({
|
||||
instance,
|
||||
setInstance,
|
||||
vault,
|
||||
setVault,
|
||||
}) => {
|
||||
const [vaultPath, setVaultPath] = useState("")
|
||||
|
||||
const unlock = useCallback(
|
||||
async (profile: string, password: string) => {
|
||||
const vault = await instance!.getProfile(profile!)
|
||||
await vault.unlock(password)
|
||||
setVault(vault)
|
||||
},
|
||||
[instance]
|
||||
)
|
||||
|
||||
const clearInstance = useCallback(() => {
|
||||
setVaultPath("")
|
||||
setInstance(undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const existingPath = get(Key.LAST_VAULT_PATH)
|
||||
if (existingPath != null) {
|
||||
setVaultPath(existingPath)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (vaultPath) {
|
||||
const instance = new OnePassword({
|
||||
path: vaultPath,
|
||||
adapter: electronAdapter,
|
||||
})
|
||||
setInstance(instance)
|
||||
set(Key.LAST_VAULT_PATH, vaultPath)
|
||||
} else {
|
||||
setInstance(undefined)
|
||||
remove(Key.LAST_VAULT_PATH)
|
||||
}
|
||||
}, [vaultPath])
|
||||
|
||||
if (!instance) {
|
||||
return <PickOPVault setPath={setVaultPath} />
|
||||
}
|
||||
if (!vault) {
|
||||
return <Unlock 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>
|
||||
)
|
||||
}
|
28
packages/web/src/styles.ts
Normal file
28
packages/web/src/styles.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { css } from "@emotion/css"
|
||||
|
||||
export const scrollbar = css`
|
||||
&&::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
}
|
||||
&&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&&:hover,
|
||||
&&:active {
|
||||
&&::-webkit-scrollbar-thumb {
|
||||
background: #8883;
|
||||
transition: 0.1s;
|
||||
border-radius: 6px;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #6663;
|
||||
}
|
||||
}
|
||||
&&::-webkit-scrollbar-thumb:hover {
|
||||
background: #ddd;
|
||||
transition: 0.1s;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #555;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
31
packages/web/src/utils/ipc-adapter.ts
Normal file
31
packages/web/src/utils/ipc-adapter.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Buffer } from "buffer"
|
||||
import type { IAdapter } from "opvault.js/src/adapters"
|
||||
import type { IPC } from "../electron/ipc-types"
|
||||
import { memoize } from "./memoize"
|
||||
|
||||
declare const ipcRenderer: Electron.IpcRenderer
|
||||
|
||||
export async function openDirectory() {
|
||||
return ipc.showDirectoryPicker()
|
||||
}
|
||||
|
||||
export const electronAdapter: IAdapter = {
|
||||
fs: {
|
||||
exists: path => ipc.pathExists(path),
|
||||
readBuffer: path => ipc.readBuffer(path).then(Buffer.from),
|
||||
readFile: path => ipc.readFile(path),
|
||||
readdir: path => ipc.readdir(path),
|
||||
writeFile: (path, data) => ipc.writeFile(path, data),
|
||||
isDirectory: path => ipc.isDirectory(path),
|
||||
},
|
||||
subtle: crypto.subtle,
|
||||
}
|
||||
|
||||
const ipc = new Proxy<IPC>({} as any, {
|
||||
get: memoize(
|
||||
(_, channel: string) =>
|
||||
(...args: any[]) =>
|
||||
ipcRenderer.invoke(`service-${channel}`, ...args),
|
||||
(_, name) => name
|
||||
),
|
||||
})
|
26
packages/web/src/utils/localStorage.ts
Normal file
26
packages/web/src/utils/localStorage.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export const enum Key {
|
||||
LAST_VAULT_PATH = "lastVaultPath",
|
||||
}
|
||||
|
||||
interface StoredData {
|
||||
[Key.LAST_VAULT_PATH]: string
|
||||
}
|
||||
|
||||
export function get<K extends keyof StoredData>(key: K) {
|
||||
try {
|
||||
const value = localStorage.getItem(key)
|
||||
return value == null ? undefined : (JSON.parse(value!) as StoredData[K])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function set<K extends keyof StoredData>(key: K, value: StoredData[K]) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function remove(key: keyof StoredData) {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {}
|
||||
}
|
18
packages/web/src/utils/memoize.ts
Normal file
18
packages/web/src/utils/memoize.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export function memoize<T extends (...args: any[]) => any, K>(
|
||||
fn: T,
|
||||
getKey: (...args: Parameters<T>) => K
|
||||
): T {
|
||||
const map = new Map<K, ReturnType<T>>()
|
||||
function memoized(this: any, ...args: Parameters<T>): ReturnType<T> {
|
||||
const key = getKey(...args)
|
||||
if (map.has(key)) {
|
||||
return map.get(key)!
|
||||
} else {
|
||||
const value = fn.apply(this, args)
|
||||
map.set(key, value)
|
||||
return value
|
||||
}
|
||||
}
|
||||
Object.defineProperty(memoized, "name", { value: fn.name })
|
||||
return memoized as any
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user