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({
|
build({
|
||||||
bundle: true,
|
bundle: true,
|
||||||
define: {},
|
define: {},
|
||||||
entryPoints: ["./src/electron/index.ts"],
|
entryPoints: ["./src/electron/index.ts", "./src/electron/preload.ts"],
|
||||||
outdir: "./dist/main",
|
outdir: "./dist/main",
|
||||||
external: builtinModules.concat("electron"),
|
external: builtinModules.concat("electron"),
|
||||||
target: ["chrome90"],
|
target: ["chrome90"],
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
<title>OPVault Viewer</title>
|
<title>OPVault Viewer</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,40 +1,26 @@
|
|||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
import type { Vault } from "opvault.js"
|
import type { Vault, OnePassword } from "opvault.js"
|
||||||
import { OnePassword } from "opvault.js"
|
|
||||||
import { getBrowserAdapter } from "opvault.js/src/adapters/browser"
|
|
||||||
import { VaultView } from "./pages/Vault"
|
import { VaultView } from "./pages/Vault"
|
||||||
import { PickOPVault } from "./pages/PickOPVault"
|
import { VaultPicker } from "./pages/VaultPicker"
|
||||||
import { Unlock } from "./pages/Unlock"
|
|
||||||
|
|
||||||
export const App: React.FC = () => {
|
export const App: React.FC = () => {
|
||||||
const [instance, setInstance] = useState<OnePassword>()
|
const [instance, setInstance] = useState<OnePassword>()
|
||||||
const [vault, setVault] = useState<Vault>()
|
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(() => {
|
const onLock = useCallback(() => {
|
||||||
vault?.lock()
|
vault?.lock()
|
||||||
setVault(undefined)
|
setVault(undefined)
|
||||||
}, [vault])
|
}, [vault])
|
||||||
|
|
||||||
if (!instance) {
|
|
||||||
return <PickOPVault setHandle={setHandle} />
|
|
||||||
}
|
|
||||||
if (!vault) {
|
if (!vault) {
|
||||||
return <Unlock instance={instance} onUnlock={unlock} />
|
return (
|
||||||
|
<VaultPicker
|
||||||
|
instance={instance}
|
||||||
|
setInstance={setInstance}
|
||||||
|
vault={vault}
|
||||||
|
setVault={setVault}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import styled from "@emotion/styled"
|
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 { useEffect, useState } from "react"
|
||||||
|
import { useTranslate } from "../i18n"
|
||||||
import { CategoryIcon } from "./CategoryIcon"
|
import { CategoryIcon } from "./CategoryIcon"
|
||||||
import { ItemDates } from "./ItemDates"
|
import { ItemDates } from "./ItemDates"
|
||||||
import {
|
import {
|
||||||
@ -13,6 +14,7 @@ import { ItemWarning } from "./ItemWarning"
|
|||||||
|
|
||||||
interface ItemViewProps {
|
interface ItemViewProps {
|
||||||
item: Item
|
item: Item
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header = styled.div`
|
const Header = styled.div`
|
||||||
@ -55,8 +57,32 @@ const AttachmentContainer = styled.div`
|
|||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
`
|
`
|
||||||
|
|
||||||
export const ItemView: React.FC<ItemViewProps> = ({ item }) => (
|
const FieldsView: React.FC<{ fields?: ItemField[] }> = ({ fields }) =>
|
||||||
<Container>
|
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>
|
<Inner>
|
||||||
<ItemWarning item={item} />
|
<ItemWarning item={item} />
|
||||||
<Header>
|
<Header>
|
||||||
@ -84,13 +110,7 @@ export const ItemView: React.FC<ItemViewProps> = ({ item }) => (
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!!item.details.fields?.length && (
|
<FieldsView fields={item.details.fields} />
|
||||||
<div style={{ marginBottom: 20 }}>
|
|
||||||
{item.details.fields!.map((field, i) => (
|
|
||||||
<ItemDetailsFieldView key={i} field={field} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{item.details.notesPlain != null && (
|
{item.details.notesPlain != null && (
|
||||||
<ExtraField>
|
<ExtraField>
|
||||||
@ -101,16 +121,7 @@ export const ItemView: React.FC<ItemViewProps> = ({ item }) => (
|
|||||||
</ExtraField>
|
</ExtraField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!!item.overview.tags?.length && (
|
<TagsView tags={item.overview.tags} />
|
||||||
<ExtraField>
|
|
||||||
<FieldTitle>tags</FieldTitle>
|
|
||||||
<div>
|
|
||||||
{item.overview.tags!.map((tag, i) => (
|
|
||||||
<Tag key={i}>{tag}</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ExtraField>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{item.attachments.length > 0 && (
|
{item.attachments.length > 0 && (
|
||||||
<ExtraField>
|
<ExtraField>
|
||||||
|
@ -62,7 +62,7 @@ export const ItemList: React.FC<ListProps> = ({ items, onSelect, selected }) =>
|
|||||||
<Icon fill="#FFF" category={item.category} />
|
<Icon fill="#FFF" category={item.category} />
|
||||||
<div>
|
<div>
|
||||||
<ItemTitle>{item.overview.title!}</ItemTitle>
|
<ItemTitle>{item.overview.title!}</ItemTitle>
|
||||||
<ItemDescription>{item.overview.ainfo}</ItemDescription>
|
<ItemDescription>{item.overview.ainfo || " "}</ItemDescription>
|
||||||
</div>
|
</div>
|
||||||
</ItemView>
|
</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
|
// Modules to control application life and create native browser window
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import { app, BrowserWindow, Menu } from "electron"
|
import { app, BrowserWindow, Menu } from "electron"
|
||||||
|
import "./ipc"
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
@ -13,7 +14,7 @@ function createWindow() {
|
|||||||
icon: join(__dirname, "../512x512.png"),
|
icon: join(__dirname, "../512x512.png"),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
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 { contextBridge, ipcRenderer } from "electron"
|
||||||
import { nodeAdapter } from "opvault.js/src/adapters/node"
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("nodeAdapter", nodeAdapter)
|
contextBridge.exposeInMainWorld("ipcRenderer", {
|
||||||
|
invoke: ipcRenderer.invoke,
|
||||||
|
})
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# /* spellchecker: disable */
|
# /* spellchecker: disable */
|
||||||
label_choose_a_vault:
|
label_choose_a_vault:
|
||||||
en: Pick a vault here.
|
en: Pick a vault
|
||||||
fr: Choisir un coffre ici.
|
fr: Choisir un coffre
|
||||||
|
|
||||||
label_no_vault_selected:
|
label_no_vault_selected:
|
||||||
en: No vault is selected.
|
en: No vault is selected.
|
||||||
@ -15,10 +15,18 @@ label_created_at:
|
|||||||
en: Created At
|
en: Created At
|
||||||
fr: Créé
|
fr: Créé
|
||||||
|
|
||||||
|
label_password_placeholder:
|
||||||
|
en: Master Password
|
||||||
|
fr: Mot de passe principal
|
||||||
|
|
||||||
noun_vault:
|
noun_vault:
|
||||||
en: vault
|
en: vault
|
||||||
fr: coffre
|
fr: coffre
|
||||||
|
|
||||||
|
noun_tags:
|
||||||
|
en: tags
|
||||||
|
fr: mots-clés
|
||||||
|
|
||||||
action_lock:
|
action_lock:
|
||||||
en: Lock
|
en: Lock
|
||||||
fr: Vérouiller
|
fr: Vérouiller
|
||||||
@ -26,3 +34,7 @@ action_lock:
|
|||||||
action_unlock:
|
action_unlock:
|
||||||
en: Unlock
|
en: Unlock
|
||||||
fr: Déverouiller
|
fr: Déverouiller
|
||||||
|
|
||||||
|
action_go_back:
|
||||||
|
en: Back
|
||||||
|
fr: Revenir
|
||||||
|
@ -12,34 +12,48 @@ body {
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen", "Ubuntu",
|
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen", "Ubuntu",
|
||||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
:root {
|
||||||
|
--page-background: #fff;
|
||||||
--color: #000;
|
--color: #000;
|
||||||
--titlebar-height: 46px;
|
--titlebar-height: 46px;
|
||||||
--titlebar-height: 0px;
|
--titlebar-height: 0px;
|
||||||
--label-background: #ddd;
|
--label-background: #ddd;
|
||||||
--selected-background: #c9c9c9;
|
--selected-background: #c9c9c9;
|
||||||
--hover-background: #ddd;
|
--hover-background: #ddd;
|
||||||
|
--border-color: #ddd;
|
||||||
--monospace: D2Coding, source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
--monospace: D2Coding, source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||||
monospace;
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root.mac {
|
||||||
|
--page-background: #f7f7f7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
background-color: var(--page-background);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body {
|
:root {
|
||||||
color: #fff;
|
|
||||||
--color: #fff;
|
--color: #fff;
|
||||||
--label-background: #353535;
|
--label-background: #353535;
|
||||||
--selected-background: #353535;
|
--selected-background: #353535;
|
||||||
--selected-background: #15539e;
|
--border-color: #333;
|
||||||
--hover-background: #222;
|
--hover-background: #222;
|
||||||
|
--page-background: #292929;
|
||||||
}
|
}
|
||||||
#root {
|
body {
|
||||||
background-color: #292929;
|
color: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,18 +110,3 @@ button[type="submit"] {
|
|||||||
select {
|
select {
|
||||||
padding: 5px 10px;
|
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 { LocaleContextProvider } from "./i18n"
|
||||||
import "./index.scss"
|
import "./index.scss"
|
||||||
|
|
||||||
|
if (navigator.platform === "MacIntel") {
|
||||||
|
document.documentElement.classList.add("mac")
|
||||||
|
}
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
{/* <TitleBar /> */}
|
{/* <TitleBar /> */}
|
||||||
@ -11,5 +15,5 @@ render(
|
|||||||
<App />
|
<App />
|
||||||
</LocaleContextProvider>
|
</LocaleContextProvider>
|
||||||
</React.StrictMode>,
|
</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 type { OnePassword } from "opvault.js"
|
||||||
import styled from "@emotion/styled"
|
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"
|
import { useTranslate } from "../i18n"
|
||||||
|
|
||||||
const Container = styled.form`
|
const Container = styled.div`
|
||||||
padding: 20px;
|
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;
|
text-align: center;
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 5px;
|
||||||
`
|
`
|
||||||
|
|
||||||
export const Unlock: React.FC<{
|
export const Unlock: React.FC<{
|
||||||
instance: OnePassword
|
instance: OnePassword
|
||||||
onUnlock(profile: string, password: string): void
|
onUnlock(profile: string, password: string): void
|
||||||
}> = ({ onUnlock, instance }) => {
|
onReturn(): void
|
||||||
|
}> = ({ onUnlock, onReturn, instance }) => {
|
||||||
const t = useTranslate()
|
const t = useTranslate()
|
||||||
const [profiles, setProfiles] = useState<string[]>(() => [])
|
const [profiles, setProfiles] = useState<string[]>(() => [])
|
||||||
const [profile, setProfile] = useState<string>()
|
const [profile, setProfile] = useState<string>()
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
|
|
||||||
const unlock = useCallback(
|
const unlock = useCallback(
|
||||||
(e: React.FormEvent) => {
|
(e?: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e?.preventDefault()
|
||||||
|
|
||||||
if (!profile) return
|
if (!profile) return
|
||||||
onUnlock(profile, password)
|
onUnlock(profile, password)
|
||||||
@ -28,6 +83,15 @@ export const Unlock: React.FC<{
|
|||||||
[onUnlock, profile, password]
|
[onUnlock, profile, password]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onKeyUp = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
unlock()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[unlock]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
instance.getProfileNames().then(profiles => {
|
instance.getProfileNames().then(profiles => {
|
||||||
setProfiles(profiles)
|
setProfiles(profiles)
|
||||||
@ -36,26 +100,40 @@ export const Unlock: React.FC<{
|
|||||||
}, [instance])
|
}, [instance])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container onSubmit={unlock}>
|
<Container>
|
||||||
<div>
|
<div style={{ marginBottom: 10 }}>
|
||||||
<select value={profile} onChange={e => setProfile(e.currentTarget.value)}>
|
<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 => (
|
{profiles.map(p => (
|
||||||
<option key={p} value={p}>
|
<option key={p} value={p}>
|
||||||
{t.noun_vault}: {p}
|
{t.noun_vault}: {p}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ margin: "10px 0" }}>
|
<div style={{ margin: "10px 0", position: "relative" }}>
|
||||||
<input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.currentTarget.value)}
|
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>
|
</div>
|
||||||
<button type="submit" disabled={!profile || !password}>
|
|
||||||
{t.action_unlock}
|
|
||||||
</button>
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,24 +1,28 @@
|
|||||||
import styled from "@emotion/styled"
|
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 type { Vault, Item } from "opvault.js"
|
||||||
import { Category } from "opvault.js"
|
import { Category } from "opvault.js"
|
||||||
|
import { FiLock } from "react-icons/fi"
|
||||||
import { IoSearch } from "react-icons/io5"
|
import { IoSearch } from "react-icons/io5"
|
||||||
import { ItemList } from "../components/ItemList"
|
import { ItemList } from "../components/ItemList"
|
||||||
import { ItemView } from "../components/Item"
|
import { ItemView } from "../components/Item"
|
||||||
import { reactIconClass } from "../components/CategoryIcon"
|
import { reactIconClass } from "../components/CategoryIcon"
|
||||||
import { useTranslate } from "../i18n/index"
|
import { useTranslate } from "../i18n/index"
|
||||||
|
import { scrollbar } from "../styles"
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
height: calc(100vh - var(--titlebar-height));
|
height: calc(100vh - var(--titlebar-height));
|
||||||
`
|
`
|
||||||
const ListContainer = styled.div`
|
const ListContainer = styled.div`
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
width: 300px;
|
width: 300px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
background: #202020;
|
background: #202020;
|
||||||
|
border-right-color: transparent;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
const ItemContainer = styled.div`
|
const ItemContainer = styled.div`
|
||||||
@ -27,15 +31,20 @@ const ItemContainer = styled.div`
|
|||||||
`
|
`
|
||||||
const SearchContainer = styled.div`
|
const SearchContainer = styled.div`
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 10px 0;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
`
|
`
|
||||||
const SortContainer = styled.div`
|
const SortContainer = styled.div`
|
||||||
margin: 10px 10px;
|
margin: 10px 10px;
|
||||||
`
|
`
|
||||||
|
const LockButton = styled.button`
|
||||||
|
&& {
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
`
|
||||||
const SearchInput = styled.input`
|
const SearchInput = styled.input`
|
||||||
--margin: 10px;
|
--margin: 10px;
|
||||||
width: calc(100% - var(--margin) * 2);
|
width: calc(100% - var(--margin) * 2 + 9px);
|
||||||
margin: 0 var(--margin);
|
margin: 0 var(--margin);
|
||||||
padding-left: 2em !important;
|
padding-left: 2em !important;
|
||||||
`
|
`
|
||||||
@ -66,9 +75,9 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
|||||||
case SortBy.Name:
|
case SortBy.Name:
|
||||||
return (a, b) => (a.overview.title ?? "").localeCompare(b?.overview.title ?? "")
|
return (a, b) => (a.overview.title ?? "").localeCompare(b?.overview.title ?? "")
|
||||||
case SortBy.CreatedAt:
|
case SortBy.CreatedAt:
|
||||||
return (a, b) => a.createdAt - b.createdAt
|
return (a, b) => b.createdAt - a.createdAt
|
||||||
case SortBy.UpdatedAt:
|
case SortBy.UpdatedAt:
|
||||||
return (a, b) => a.updatedAt - b.updatedAt
|
return (a, b) => b.updatedAt - a.updatedAt
|
||||||
}
|
}
|
||||||
}, [sortBy])
|
}, [sortBy])
|
||||||
|
|
||||||
@ -95,22 +104,21 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<ListContainer>
|
<ListContainer className={scrollbar}>
|
||||||
<div
|
<div style={{ margin: "10px 10px", display: "flex" }}>
|
||||||
style={{
|
<LockButton onClick={onLock} title={t.action_lock}>
|
||||||
margin: "10px 10px",
|
<FiLock />
|
||||||
}}
|
</LockButton>
|
||||||
>
|
<SearchContainer>
|
||||||
<button onClick={onLock}>{t.action_lock}</button>
|
<SearchInput
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<SearchIcon className={reactIconClass} />
|
||||||
|
</SearchContainer>
|
||||||
</div>
|
</div>
|
||||||
<SearchContainer>
|
|
||||||
<SearchInput
|
|
||||||
type="search"
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<SearchIcon className={reactIconClass} />
|
|
||||||
</SearchContainer>
|
|
||||||
<SortContainer>
|
<SortContainer>
|
||||||
<select
|
<select
|
||||||
style={{ width: "100%" }}
|
style={{ width: "100%" }}
|
||||||
@ -124,7 +132,9 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
|||||||
</SortContainer>
|
</SortContainer>
|
||||||
<ItemList items={filtered} onSelect={setItem} selected={item} />
|
<ItemList items={filtered} onSelect={setItem} selected={item} />
|
||||||
</ListContainer>
|
</ListContainer>
|
||||||
<ItemContainer>{item && <ItemView item={item} />}</ItemContainer>
|
<ItemContainer>
|
||||||
|
{item && <ItemView className={scrollbar} item={item} />}
|
||||||
|
</ItemContainer>
|
||||||
</Container>
|
</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