Compare commits
4 Commits
1.0.0-beta
...
1.0.0-beta
Author | SHA1 | Date | |
---|---|---|---|
7dcb2fc7a5 | |||
92e0fddbfc | |||
b4e37bed2d | |||
30e5a1f484 |
BIN
.github/screenshot.png
vendored
BIN
.github/screenshot.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 380 KiB |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,3 @@
|
||||
drafts
|
||||
node_modules
|
||||
mochawesome-report
|
||||
lib
|
||||
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,6 +1,5 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"cSpell.words": ["autolock"],
|
||||
"cSpell.ignorePaths": [
|
||||
"**/package-lock.json",
|
||||
"**/node_modules/**",
|
||||
@ -10,4 +9,4 @@
|
||||
".vscode-insiders",
|
||||
"i18n.json"
|
||||
]
|
||||
}
|
||||
}
|
22
README.md
22
README.md
@ -1,27 +1,5 @@
|
||||
# opvault.js
|
||||
|
||||

|
||||
|
||||
## Lecteur de coffres OnePassword libre
|
||||
|
||||
Lire vos coffres OnePassword sur n’importe quelle plateforme. Pour commencer, vous pouvez [télécharger une version compilée](../../../releases) pour votre système d’exploitation, ou [suivre les instructions de compilation](#build) ci-dessous.
|
||||
|
||||
## OnePassword Vault Reader
|
||||
|
||||
Read your OnePassword vaults on all platform. To start, you can [download a prebuilt binary](../../../releases) for your OS, or [follow the build instructions](#build) below.
|
||||
|
||||
## Capture d’écran / Screenshot
|
||||
|
||||
<img alt="linux screenshot" src=".github/screenshot.png" width="700" />
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
cd packages/web
|
||||
pnpm run bundle
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
|
44
package.json
44
package.json
@ -4,47 +4,45 @@
|
||||
"main": "lib/index.js",
|
||||
"repository": "https://git.aet.ac/aet/opvault.js.git",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"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"
|
||||
"repl": "node -r ts-node/register/transpile-only src/repl.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.3.0",
|
||||
"@types/chai": "^4.2.22",
|
||||
"@types/chai-as-promised": "^7.1.4",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768",
|
||||
"@types/node": "^17.0.6",
|
||||
"@types/sinon": "^10.0.6",
|
||||
"@types/sinon-chai": "^3.2.8",
|
||||
"@types/node": "^16.10.3",
|
||||
"@types/sinon": "^10.0.4",
|
||||
"@types/sinon-chai": "^3.2.5",
|
||||
"@types/wicg-file-system-access": "^2020.9.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.8.1",
|
||||
"@typescript-eslint/parser": "5.8.1",
|
||||
"@typescript-eslint/eslint-plugin": "4.33.0",
|
||||
"@typescript-eslint/parser": "4.33.0",
|
||||
"chai": "^4.3.4",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"chalk": "^4.1.2",
|
||||
"eslint": "8.6.0",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-import-resolver-typescript": "2.5.0",
|
||||
"eslint-plugin-import": "2.25.3",
|
||||
"eslint-plugin-react": "7.28.0",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"eslint-plugin-import": "2.24.2",
|
||||
"eslint-plugin-react": "7.26.1",
|
||||
"eslint-plugin-react-hooks": "4.2.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"marked": "^4.0.8",
|
||||
"mocha": "^9.1.3",
|
||||
"mochawesome": "^7.0.1",
|
||||
"prettier": "^2.5.1",
|
||||
"marked": "^3.0.8",
|
||||
"mocha": "^9.1.2",
|
||||
"mochawesome": "^6.3.0",
|
||||
"prettier": "^2.4.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"sass": "^1.45.2",
|
||||
"sinon": "^12.0.1",
|
||||
"sass": "^1.43.2",
|
||||
"sinon": "^11.1.2",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"tslib": "^2.3.1",
|
||||
"ts-node": "^10.4.0",
|
||||
"tsconfig-paths": "^3.12.0",
|
||||
"typescript": "^4.5.4"
|
||||
"ts-node": "^10.2.1",
|
||||
"tsconfig-paths": "^3.11.0",
|
||||
"typescript": "^4.4.3"
|
||||
},
|
||||
"prettier": {
|
||||
"arrowParens": "avoid",
|
||||
|
@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "opvault-adapters",
|
||||
"dependencies": {
|
||||
"opvault.js": "*"
|
||||
}
|
||||
}
|
184
packages/opvault.js/LICENSE
Normal file
184
packages/opvault.js/LICENSE
Normal file
@ -0,0 +1,184 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007
|
||||
aet
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license
|
||||
document, but changing it is not allowed.
|
||||
|
||||
This version of the GNU Lesser General
|
||||
Public License incorporates the terms and conditions of version 3 of the GNU
|
||||
General Public License, supplemented by the additional permissions listed
|
||||
below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
|
||||
|
||||
As used herein, "this
|
||||
License" refers to version 3 of the GNU Lesser General Public License, and the
|
||||
"GNU GPL" refers to version 3 of the GNU General Public License.
|
||||
|
||||
|
||||
|
||||
|
||||
"The Library" refers to a covered work governed by this License, other than an
|
||||
Application or a Combined Work as defined below.
|
||||
|
||||
|
||||
|
||||
An "Application"
|
||||
is any work that makes use of an interface provided by the Library, but which is
|
||||
not otherwise based on the Library. Defining a subclass of a class defined by the
|
||||
Library is deemed a mode of using an interface provided by the Library.
|
||||
|
||||
|
||||
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an Application with
|
||||
the Library. The particular version of the Library with which the Combined Work
|
||||
was made is also called the "Linked Version".
|
||||
|
||||
|
||||
|
||||
The "Minimal
|
||||
Corresponding Source" for a Combined Work means the Corresponding Source for the
|
||||
Combined Work, excluding any source code for portions of the Combined Work that,
|
||||
considered in isolation, are based on the Application, and not on the Linked
|
||||
Version.
|
||||
|
||||
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work
|
||||
means the object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1.
|
||||
Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under
|
||||
sections 3 and 4 of this License without being bound by section 3 of the GNU
|
||||
GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library,
|
||||
and, in your modifications, a facility refers to a function or data to be
|
||||
supplied by an Application that uses the facility (other than as an argument
|
||||
passed when the facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort
|
||||
to ensure that, in the event an Application does not supply the function or data,
|
||||
the facility still operates, and performs whatever part of its purpose remains
|
||||
meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional
|
||||
permissions of this License applicable to that copy.
|
||||
|
||||
3. Object Code
|
||||
Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an
|
||||
Application may incorporate material from a header file that is part of the
|
||||
Library. You may convey such object code under terms of your choice, provided
|
||||
that, if the incorporated material is not limited to numerical parameters, data
|
||||
structure layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give
|
||||
prominent notice with each copy of the object code that the Library is used in it
|
||||
and that the Library and its use are covered by this License.
|
||||
|
||||
b) Accompany
|
||||
the object code with a copy of the GNU GPL and this license document.
|
||||
|
||||
4.
|
||||
Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice
|
||||
that, taken together, effectively do not restrict modification of the portions of
|
||||
the Library contained in the Combined Work and reverse engineering for debugging
|
||||
such modifications, if you also do each of the following:
|
||||
|
||||
a) Give
|
||||
prominent notice with each copy of the Combined Work that the Library is used in
|
||||
it and that the Library and its use are covered by this License.
|
||||
|
||||
b)
|
||||
Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among these notices, as
|
||||
well as a reference directing the user to the copies of the GNU GPL and this
|
||||
license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the
|
||||
Minimal Corresponding Source under the terms of this License, and the
|
||||
Corresponding Application Code in a form suitable for, and under terms that
|
||||
permit, the user to recombine or relink the Application with a modified version
|
||||
of the Linked Version to produce a modified Combined Work, in the manner
|
||||
specified by section 6 of the GNU GPL for conveying Corresponding Source.
|
||||
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the Library. A
|
||||
suitable mechanism is one that (a) uses at run time a copy of the Library already
|
||||
present on the user's computer system, and (b) will operate properly with a
|
||||
modified version of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would
|
||||
otherwise be required to provide such information under section 6 of the GNU GPL,
|
||||
and only to the extent that such information is necessary to install and execute
|
||||
a modified version of the Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If you use option
|
||||
4d0, the Installation Information must accompany the Minimal Corresponding Source
|
||||
and Corresponding Application Code. If you use option 4d1, you must provide the
|
||||
Installation Information in the manner specified by section 6 of the GNU GPL for
|
||||
conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place
|
||||
library facilities that are a work based on the Library side by side in a single
|
||||
library together with other library facilities that are not Applications and are
|
||||
not covered by this License, and convey such a combined library under terms of
|
||||
your choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined
|
||||
library with a copy of the same work based on the Library, uncombined with any
|
||||
other library facilities, conveyed under the terms of this License.
|
||||
|
||||
b)
|
||||
Give prominent notice with the combined library that part of it is a work based
|
||||
on the Library, and explaining where to find the accompanying uncombined form of
|
||||
the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public
|
||||
License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing
|
||||
version number. If the Library as you received it specifies that a certain
|
||||
numbered version of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and conditions either
|
||||
of that published version or of any later version published by the Free Software
|
||||
Foundation. If the Library as you received it does not specify a version number
|
||||
of the GNU Lesser General Public License, you may choose any version of the GNU
|
||||
Lesser General Public License ever published by the Free Software Foundation.
|
||||
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide whether
|
||||
future versions of the GNU Lesser General Public License shall apply, that
|
||||
proxy's public statement of acceptance of any version is permanent authorization
|
||||
for you to choose that version for the Library.
|
@ -1,9 +0,0 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
code,
|
||||
pre,
|
||||
.tsd-signature {
|
||||
font-family: D2Coding, Consolas, Menlo, Monaco, "Roboto Mono", monospace;
|
||||
}
|
@ -4,7 +4,7 @@
|
||||
"version": "0.0.1",
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"build": "rollup -c; prettier --write lib >/dev/null",
|
||||
"build": "rollup -c; cp src/adapters/index.d.ts lib/adapters/; prettier --write lib >/dev/null",
|
||||
"build:docs": "typedoc --out docs src/index.ts --excludePrivate"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -15,9 +15,9 @@
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-replace": "^3.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rollup": "^2.61.1",
|
||||
"rollup-plugin-ts": "^2.0.4",
|
||||
"typedoc": "^0.22.10"
|
||||
"prettier": "^2.4.1",
|
||||
"rollup": "^2.58.0",
|
||||
"rollup-plugin-ts": "^1.4.7",
|
||||
"typedoc": "^0.22.7"
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import { dependencies } from "./package.json"
|
||||
export default () => ({
|
||||
input: {
|
||||
index: "./src/index.ts",
|
||||
"adapters/node": "./src/adapters/node.ts",
|
||||
},
|
||||
external: builtinModules.concat(Object.keys(dependencies)),
|
||||
output: {
|
||||
@ -21,8 +22,6 @@ export default () => ({
|
||||
preventAssignment: true,
|
||||
values: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
'require("./adapter").nodeAdapter':
|
||||
'import("./adapter").then(x => x.nodeAdapter)',
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Buffer } from "buffer"
|
||||
import type { IAdapter, IFileSystem } from "opvault.js/src/adapter"
|
||||
import type { IAdapter, IFileSystem } from "./index"
|
||||
|
||||
function normalize(path: string) {
|
||||
return path.replace(/^\//, "")
|
@ -1,6 +1,3 @@
|
||||
import { promises as fs, existsSync } from "fs"
|
||||
import { webcrypto } from "crypto"
|
||||
|
||||
/**
|
||||
* An object that implements basic file system functionalities.
|
||||
*/
|
||||
@ -56,19 +53,3 @@ export interface IAdapter {
|
||||
*/
|
||||
subtle: SubtleCrypto
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Node.js adapter. This can be used while using `opvault.js`
|
||||
* in a Node.js environment.
|
||||
*/
|
||||
export const nodeAdapter: IAdapter = {
|
||||
fs: {
|
||||
readFile: path => fs.readFile(path, "utf-8"),
|
||||
readBuffer: path => fs.readFile(path),
|
||||
writeFile: fs.writeFile,
|
||||
readdir: fs.readdir,
|
||||
isDirectory: async path => fs.stat(path).then(x => x.isDirectory()),
|
||||
exists: async path => existsSync(path),
|
||||
},
|
||||
subtle: (webcrypto as any).subtle,
|
||||
}
|
20
packages/opvault.js/src/adapters/node.ts
Normal file
20
packages/opvault.js/src/adapters/node.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { promises as fs, existsSync } from "fs"
|
||||
import { webcrypto } from "crypto"
|
||||
|
||||
import type { IAdapter } from "./index"
|
||||
|
||||
/**
|
||||
* Default Node.js adapter. This can be used while using `opvault.js`
|
||||
* in a Node.js environment.
|
||||
*/
|
||||
export const nodeAdapter: IAdapter = {
|
||||
fs: {
|
||||
readFile: path => fs.readFile(path, "utf-8"),
|
||||
readBuffer: path => fs.readFile(path),
|
||||
writeFile: fs.writeFile,
|
||||
readdir: fs.readdir,
|
||||
isDirectory: async path => fs.stat(path).then(x => x.isDirectory()),
|
||||
exists: async path => existsSync(path),
|
||||
},
|
||||
subtle: (webcrypto as any).subtle,
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { Buffer } from "buffer"
|
||||
import type { IAdapter, IFileSystem } from "opvault.js/src/adapter"
|
||||
import type { IAdapter, IFileSystem } from "./index"
|
||||
|
||||
export class FileSystem implements IFileSystem {
|
||||
private paths = new Set<string>()
|
@ -1,6 +1,6 @@
|
||||
import { resolve, extname, basename } from "path"
|
||||
import invariant from "tiny-invariant"
|
||||
import type { IFileSystem } from "./adapter"
|
||||
import type { IFileSystem } from "./adapters"
|
||||
import { once } from "./util"
|
||||
|
||||
export type OnePasswordFileManager = ReturnType<typeof OnePasswordFileManager>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { resolve } from "path"
|
||||
import { Vault } from "./models/vault"
|
||||
import type { IAdapter } from "./adapter"
|
||||
import type { IAdapter } from "./adapters"
|
||||
import { asyncMap } from "./util"
|
||||
|
||||
export type { Vault } from "./models/vault"
|
||||
@ -18,7 +18,7 @@ interface IOptions {
|
||||
/**
|
||||
* Adapter used to interact with the file system and cryptography modules
|
||||
*/
|
||||
adapter?: IAdapter | Promise<IAdapter>
|
||||
adapter?: IAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
@ -26,11 +26,11 @@ interface IOptions {
|
||||
*/
|
||||
export class OnePassword {
|
||||
readonly #path: string
|
||||
readonly #adapter: IAdapter | Promise<IAdapter>
|
||||
readonly #adapter: IAdapter
|
||||
|
||||
constructor({
|
||||
path,
|
||||
adapter = process.browser ? null! : require("./adapter").nodeAdapter,
|
||||
adapter = process.browser ? null : require("./adapters/node").nodeAdapter,
|
||||
}: IOptions) {
|
||||
this.#adapter = adapter
|
||||
this.#path = path
|
||||
@ -40,11 +40,11 @@ export class OnePassword {
|
||||
* @returns A list of names of profiles of the current vault.
|
||||
*/
|
||||
async getProfileNames() {
|
||||
const { fs } = await this.#adapter
|
||||
const children = await fs.readdir(this.#path)
|
||||
const [fs, path] = [this.#adapter.fs, this.#path]
|
||||
const children = await fs.readdir(path)
|
||||
const profiles: string[] = []
|
||||
await asyncMap(children, async child => {
|
||||
const fullPath = resolve(this.#path, child)
|
||||
const fullPath = resolve(path, child)
|
||||
if (
|
||||
(await fs.isDirectory(fullPath)) &&
|
||||
(await fs.exists(resolve(fullPath, "profile.js")))
|
||||
@ -59,6 +59,6 @@ export class OnePassword {
|
||||
* @returns A OnePassword Vault instance.
|
||||
*/
|
||||
async getProfile(profileName: string) {
|
||||
return await Vault.of(this.#path, profileName, await this.#adapter)
|
||||
return await Vault.of(this.#path, profileName, this.#adapter)
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Buffer } from "buffer"
|
||||
import type { Crypto } from "../crypto"
|
||||
import type { Crypto } from "./crypto"
|
||||
import { invariant } from "../errors"
|
||||
|
||||
type integer = number
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { Buffer } from "buffer"
|
||||
import { decryptData } from "./decipher"
|
||||
import type { IAdapter } from "./adapter"
|
||||
import { createEventEmitter } from "./ee"
|
||||
import { HMACAssertionError } from "./errors"
|
||||
import type { i18n } from "./i18n"
|
||||
import type { ItemDetails, Overview, Profile } from "./types"
|
||||
import { setIfAbsent } from "./util"
|
||||
import type { EncryptedItem } from "./models/item"
|
||||
import { decryptData } from "../decipher"
|
||||
import type { IAdapter } from "../adapters"
|
||||
import { createEventEmitter } from "../ee"
|
||||
import { HMACAssertionError } from "../errors"
|
||||
import type { i18n } from "../i18n"
|
||||
import type { ItemDetails, Overview, Profile } from "../types"
|
||||
import { setIfAbsent } from "../util"
|
||||
import type { EncryptedItem } from "./item"
|
||||
|
||||
/** Encryption and MAC */
|
||||
export interface Cipher {
|
@ -1,5 +1,5 @@
|
||||
import type { ItemDetails, Overview } from "../types"
|
||||
import type { Crypto } from "../crypto"
|
||||
import type { Crypto } from "./crypto"
|
||||
import { Attachment } from "./attachment"
|
||||
import { NotUnlockedError } from "../errors"
|
||||
import type { Category } from "../models"
|
||||
@ -8,7 +8,7 @@ export interface EncryptedItem {
|
||||
category: string // "001"
|
||||
/** Unix seconds */
|
||||
created: integer
|
||||
d: string // details, bass64
|
||||
d: string // "b3BkYXRhMbt"
|
||||
folder: string // 32 chars
|
||||
hmac: string // base64
|
||||
k: string // base64
|
||||
@ -16,7 +16,6 @@ export interface EncryptedItem {
|
||||
tx: integer // Unix seconds
|
||||
updated: integer // Unix seconds
|
||||
uuid: string // 32 chars
|
||||
fave: number
|
||||
trashed?: boolean
|
||||
}
|
||||
|
||||
@ -59,9 +58,6 @@ export class Item {
|
||||
}
|
||||
return this.#details!
|
||||
}
|
||||
get fave() {
|
||||
return this.#data.fave
|
||||
}
|
||||
|
||||
constructor(crypto: Crypto, data: EncryptedItem) {
|
||||
this.#crypto = crypto
|
||||
|
@ -1,9 +1,9 @@
|
||||
import type { IAdapter } from "../adapter"
|
||||
import type { IAdapter } from "../adapters"
|
||||
import { HMACAssertionError, invariant } from "../errors"
|
||||
import { OnePasswordFileManager } from "../fs"
|
||||
import { i18n } from "../i18n"
|
||||
import type { EncryptedItem } from "./item"
|
||||
import { Crypto } from "../crypto"
|
||||
import { Crypto } from "./crypto"
|
||||
import { Item } from "./item"
|
||||
import type { Profile } from "../types"
|
||||
import { WeakValueMap } from "../weakMap"
|
||||
|
@ -23,39 +23,21 @@ export type TextField = {
|
||||
value: string
|
||||
designation: string
|
||||
name: string
|
||||
id?: undefined
|
||||
}
|
||||
export type BooleanField = {
|
||||
type: FieldType.Checkbox
|
||||
name: string
|
||||
value?: "✓" | string
|
||||
designation?: undefined
|
||||
id?: undefined
|
||||
}
|
||||
|
||||
export type ItemField =
|
||||
| TextField
|
||||
| BooleanField
|
||||
| {
|
||||
name: string
|
||||
designation: "username"
|
||||
value: string
|
||||
id?: undefined
|
||||
type?: undefined
|
||||
}
|
||||
| {
|
||||
name: string
|
||||
designation: "password"
|
||||
value: string
|
||||
id?: undefined
|
||||
type?: undefined
|
||||
}
|
||||
| {
|
||||
// @TODO: This currently catches all item fields.
|
||||
type: FieldType
|
||||
value: string
|
||||
designation?: string
|
||||
id?: undefined
|
||||
name: string
|
||||
}
|
||||
|
||||
@ -148,9 +130,6 @@ export interface ItemDetails {
|
||||
}[]
|
||||
/** Web form fields */
|
||||
fields?: ItemField[]
|
||||
/** Plain password items */
|
||||
backupKeys?: string[]
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
|
3
packages/web/.gitignore
vendored
3
packages/web/.gitignore
vendored
@ -1,7 +1,6 @@
|
||||
src/third-party-licenses.json
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
bundle
|
||||
*.local
|
||||
*.yml.d.ts
|
||||
*.yml.d.ts
|
||||
|
@ -1,29 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json
|
||||
|
||||
appId: com.proteria.opvault
|
||||
productName: OPVault Viewer
|
||||
files:
|
||||
- "**/*"
|
||||
icon: dist/512x512.png
|
||||
directories:
|
||||
output: bundle
|
||||
app: dist
|
||||
buildResources: dist
|
||||
mac:
|
||||
category: public.app-category.productivity
|
||||
target:
|
||||
target: dir
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
darkModeSupport: true
|
||||
linux:
|
||||
executableName: opvault
|
||||
category: Utility
|
||||
icon: 512x512.png
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
target:
|
||||
target: AppImage
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
@ -8,10 +8,10 @@ const args = process.argv.slice(2)
|
||||
build({
|
||||
bundle: true,
|
||||
define: {},
|
||||
entryPoints: ["./src/electron/index.ts", "./src/electron/preload.ts"],
|
||||
entryPoints: ["./src/electron/index.ts"],
|
||||
outdir: "./dist/main",
|
||||
external: builtinModules.concat("electron"),
|
||||
target: ["chrome96"],
|
||||
target: ["chrome90"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
sourcemap: "external",
|
||||
minify: process.env.NODE_ENV === "production",
|
||||
|
@ -11,7 +11,7 @@
|
||||
<title>OPVault Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -6,40 +6,53 @@
|
||||
"license": "GPL-3.0-only",
|
||||
"description": "OnePassword local vault viewer",
|
||||
"scripts": {
|
||||
"dev": "concurrently vite npm:start",
|
||||
"dev": "vite",
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"@emotion/css": "^11.5.0",
|
||||
"@emotion/react": "^11.5.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@rollup/plugin-yaml": "^3.1.0",
|
||||
"@types/react": "^17.0.37",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@vitejs/plugin-react": "^1.1.3",
|
||||
"@types/babel__core": "^7.1.18",
|
||||
"concurrently": "^6.5.1",
|
||||
"electron": "^16.0.5",
|
||||
"electron-builder": "^22.14.5",
|
||||
"esbuild": "^0.14.5",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@vitejs/plugin-react": "^1.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"electron": "^15.2.0",
|
||||
"electron-builder": "^22.13.1",
|
||||
"esbuild": "^0.13.6",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"opvault.js": "*",
|
||||
"sass": "^1.45.0",
|
||||
"typescript": "^4.5.4",
|
||||
"vite": "^2.7.3"
|
||||
"path-browserify": "^1.0.1",
|
||||
"react": "^17.0.0",
|
||||
"react-dom": "^17.0.0",
|
||||
"react-icons": "^4.3.1",
|
||||
"sass": "^1.43.4",
|
||||
"typescript": "^4.3.2",
|
||||
"vite": "^2.6.4"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.proteria.opvault",
|
||||
"productName": "OPVault Viewer",
|
||||
"files": [
|
||||
"**/*"
|
||||
],
|
||||
"icon": "dist/512x512.png",
|
||||
"directories": {
|
||||
"output": "bundle",
|
||||
"app": "dist",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"linux": {
|
||||
"executableName": "opvault",
|
||||
"category": "Utility",
|
||||
"icon": "512x512.png",
|
||||
"target": [
|
||||
"AppImage"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,13 +12,8 @@ fs.writeFileSync(
|
||||
dtsPath,
|
||||
`type Translation = Record<string, string>;
|
||||
declare const exportee: {
|
||||
${Object.entries(json)
|
||||
.map(
|
||||
([category, value]) =>
|
||||
`${category}: {\n${Object.keys(value)
|
||||
.map(key => ` ${key}: Translation;`)
|
||||
.join("\n")}\n };`
|
||||
)
|
||||
${Object.keys(json)
|
||||
.map(x => `${x}: Translation;`)
|
||||
.join("\n ")}
|
||||
};
|
||||
export default exportee;
|
||||
|
@ -1,42 +0,0 @@
|
||||
#!/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)
|
||||
)
|
@ -1,7 +1,5 @@
|
||||
#!/bin/sh
|
||||
./scripts/build-i18n-yml-typedef.js
|
||||
./scripts/build-third-party-license.js
|
||||
./scripts/build-package-json.js
|
||||
npx vite build
|
||||
NODE_ENV=production ./esbuild.js
|
||||
./scripts/build-package-json.js
|
||||
./node_modules/.bin/electron-builder build
|
@ -1,41 +0,0 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
@ -1,52 +1,45 @@
|
||||
/* eslint-disable import/no-unresolved */
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import type { Vault, OnePassword } from "opvault.js"
|
||||
import { useIdleTimer } from "react-idle-timer/modern"
|
||||
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 { VaultView } from "./pages/Vault"
|
||||
import { VaultPicker } from "./pages/VaultPicker"
|
||||
import { Key, useStorage } from "./utils/localStorage"
|
||||
import { PickOPVault } from "./pages/PickOPVault"
|
||||
import { Unlock } from "./pages/Unlock"
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [instance, setInstance] = useState<OnePassword>()
|
||||
const [vault, setVault] = useState<Vault>()
|
||||
|
||||
const [enableAutoLock] = useStorage(Key.ENABLE_AUTO_LOCK)
|
||||
const [autolockAfter] = useStorage(Key.AUTO_LOCK_AFTER)
|
||||
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])
|
||||
|
||||
const onAutoLock = useCallback(() => {
|
||||
if (enableAutoLock) {
|
||||
onLock()
|
||||
}
|
||||
}, [onLock, enableAutoLock])
|
||||
|
||||
const { reset, pause } = useIdleTimer({
|
||||
timeout: autolockAfter * 1000,
|
||||
onIdle: onAutoLock,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (vault) {
|
||||
reset()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}, [vault])
|
||||
|
||||
if (!instance) {
|
||||
return <PickOPVault setHandle={setHandle} />
|
||||
}
|
||||
if (!vault) {
|
||||
return (
|
||||
<VaultPicker
|
||||
instance={instance}
|
||||
setInstance={setInstance}
|
||||
vault={vault}
|
||||
setVault={setVault}
|
||||
/>
|
||||
)
|
||||
return <Unlock instance={instance} onUnlock={unlock} />
|
||||
}
|
||||
|
||||
return <VaultView onLock={onLock} vault={vault} />
|
||||
return (
|
||||
<div>
|
||||
<VaultView onLock={onLock} vault={vault} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,14 +0,0 @@
|
||||
import { useEffect, memo } from "react"
|
||||
import { useLocaleContext, useTranslate } from "./i18n"
|
||||
|
||||
export const SideEffect = memo(() => {
|
||||
const { locale } = useLocaleContext()
|
||||
const t = useTranslate()
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale
|
||||
document.title = t.label.app_name
|
||||
}, [locale])
|
||||
|
||||
return null
|
||||
})
|
@ -1,65 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -11,12 +11,10 @@ const Container = styled.div`
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 6em;
|
||||
font-size: 8em;
|
||||
text-align: center;
|
||||
padding: 20px 25px;
|
||||
word-break: break-word;
|
||||
min-width: 75vw;
|
||||
z-index: 2;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { memo } from "react"
|
||||
import { Category } from "opvault.js"
|
||||
import { cx, css } from "@emotion/css"
|
||||
import { BsBank2, BsPeopleFill } from "react-icons/bs"
|
||||
@ -78,11 +77,14 @@ interface CategoryIconProps {
|
||||
category: Category
|
||||
}
|
||||
|
||||
export const CategoryIcon = memo<CategoryIconProps>(
|
||||
({ className, category, style, fill }) => {
|
||||
const Component = getComponent(category)
|
||||
return Component ? (
|
||||
<Component className={cx(reactIconClass, className)} fill={fill} style={style} />
|
||||
) : null
|
||||
}
|
||||
)
|
||||
export const CategoryIcon: React.FC<CategoryIconProps> = ({
|
||||
className,
|
||||
category,
|
||||
style,
|
||||
fill,
|
||||
}) => {
|
||||
const Component = getComponent(category)
|
||||
return Component ? (
|
||||
<Component className={cx(reactIconClass, className)} fill={fill} style={style} />
|
||||
) : null
|
||||
}
|
||||
|
@ -1,198 +0,0 @@
|
||||
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
|
||||
}
|
@ -1,9 +1,6 @@
|
||||
import styled from "@emotion/styled"
|
||||
import type { Attachment, AttachmentMetadata, Item, ItemField } from "opvault.js"
|
||||
import type { ItemDetails } from "opvault.js/src/types"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
import { useTranslate } from "../i18n"
|
||||
import { ItemNoTitle } from "../styles"
|
||||
import type { Attachment, AttachmentMetadata, Item } from "opvault.js"
|
||||
import { useEffect, useState } from "react"
|
||||
import { CategoryIcon } from "./CategoryIcon"
|
||||
import { ItemDates } from "./ItemDates"
|
||||
import {
|
||||
@ -12,12 +9,10 @@ import {
|
||||
FieldTitle,
|
||||
ItemDetailsFieldView,
|
||||
} from "./ItemField"
|
||||
import { PasswordFieldView } from "./ItemFieldValue"
|
||||
import { ItemWarning } from "./ItemWarning"
|
||||
|
||||
interface ItemViewProps {
|
||||
item: Item
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Header = styled.div`
|
||||
@ -59,117 +54,81 @@ const AttachmentContainer = styled.div`
|
||||
display: flex;
|
||||
margin: 5px 0;
|
||||
`
|
||||
const PlainNotes = styled.p`
|
||||
white-space: pre-wrap;
|
||||
`
|
||||
|
||||
const SectionsView = memo<{ sections?: ItemDetails["sections"] }>(({ sections }) =>
|
||||
sections?.length ? (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
{sections
|
||||
.filter(s => s.fields?.some(x => x.v != null))
|
||||
.map((section, i) => (
|
||||
<div key={i}>
|
||||
{section.title != null && <SectionTitle>{section.title}</SectionTitle>}
|
||||
{section.fields?.map((field, j) => (
|
||||
<ItemFieldView key={j} field={field} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
)
|
||||
export const ItemView: React.FC<ItemViewProps> = ({ item }) => (
|
||||
<Container>
|
||||
<Inner>
|
||||
<ItemWarning item={item} />
|
||||
<Header>
|
||||
{item.details.fields == null}
|
||||
<Icon category={item.category} />
|
||||
<ItemTitle>{item.overview.title}</ItemTitle>
|
||||
</Header>
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
<pre>
|
||||
{JSON.stringify({ overview: item.overview, details: item.details }, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
const FieldsView = memo<{ fields?: ItemField[] }>(({ fields }) =>
|
||||
fields?.length ? (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
{fields.map((field, i) => (
|
||||
<ItemDetailsFieldView key={i} field={field} />
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
)
|
||||
|
||||
const TagsView = memo<{ 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>
|
||||
)
|
||||
})
|
||||
|
||||
const JSONView = memo<{ item: Item }>(({ item }) => (
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
<pre>
|
||||
{JSON.stringify({ overview: item.overview, details: item.details }, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
))
|
||||
|
||||
export const ItemView = memo<ItemViewProps>(({ className, item }) => {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<Container className={className}>
|
||||
<Inner>
|
||||
<ItemWarning item={item} />
|
||||
<Header>
|
||||
{item.details.fields == null}
|
||||
<Icon category={item.category} />
|
||||
<ItemTitle>
|
||||
{item.overview.title || <ItemNoTitle>{t.label.no_title}</ItemNoTitle>}
|
||||
</ItemTitle>
|
||||
</Header>
|
||||
|
||||
<JSONView item={item} />
|
||||
<div style={{ height: 10 }}></div>
|
||||
|
||||
<SectionsView sections={item.details.sections} />
|
||||
<FieldsView fields={item.details.fields} />
|
||||
|
||||
{item.details.notesPlain != null && (
|
||||
<ExtraField>
|
||||
<FieldTitle>notes</FieldTitle>
|
||||
<div>
|
||||
<PlainNotes>{item.details.notesPlain}</PlainNotes>
|
||||
</div>
|
||||
</ExtraField>
|
||||
)}
|
||||
|
||||
{item.details.password != null && (
|
||||
<ExtraField>
|
||||
<FieldTitle>{t.label.password}</FieldTitle>
|
||||
<PasswordFieldView field={{ v: item.details.password }} />
|
||||
</ExtraField>
|
||||
)}
|
||||
|
||||
<TagsView tags={item.overview.tags} />
|
||||
|
||||
{item.attachments.length > 0 && (
|
||||
<ExtraField>
|
||||
<FieldTitle>attachments</FieldTitle>
|
||||
<div>
|
||||
{item.attachments.map((file, i) => (
|
||||
<AttachmentView key={i} file={file} />
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
{item.details.sections
|
||||
?.filter(s => s.fields?.some(x => x.v != null))
|
||||
.map((section, i) => (
|
||||
<div key={i}>
|
||||
{section.title != null && <SectionTitle>{section.title}</SectionTitle>}
|
||||
{section.fields?.map((field, j) => (
|
||||
<ItemFieldView key={j} field={field} />
|
||||
))}
|
||||
</div>
|
||||
</ExtraField>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!!item.details.fields?.length && (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
{item.details.fields!.map((field, i) => (
|
||||
<ItemDetailsFieldView key={i} field={field} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.details.notesPlain != null && (
|
||||
<ExtraField>
|
||||
<ItemDates item={item} />
|
||||
<FieldTitle>notes</FieldTitle>
|
||||
<div>
|
||||
<p>{item.details.notesPlain}</p>
|
||||
</div>
|
||||
</ExtraField>
|
||||
</Inner>
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{!!item.overview.tags?.length && (
|
||||
<ExtraField>
|
||||
<FieldTitle>tags</FieldTitle>
|
||||
<div>
|
||||
{item.overview.tags!.map((tag, i) => (
|
||||
<Tag key={i}>{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</ExtraField>
|
||||
)}
|
||||
|
||||
{item.attachments.length > 0 && (
|
||||
<ExtraField>
|
||||
<FieldTitle>attachments</FieldTitle>
|
||||
<div>
|
||||
{item.attachments.map((file, i) => (
|
||||
<AttachmentView key={i} file={file} />
|
||||
))}
|
||||
</div>
|
||||
</ExtraField>
|
||||
)}
|
||||
|
||||
<ExtraField>
|
||||
<ItemDates item={item} />
|
||||
</ExtraField>
|
||||
</Inner>
|
||||
</Container>
|
||||
)
|
||||
|
||||
function AttachmentView({ file }: { file: Attachment }) {
|
||||
const [metadata, setMetadata] = useState<AttachmentMetadata>()
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { memo } from "react"
|
||||
import styled from "@emotion/styled"
|
||||
import type { Item } from "opvault.js"
|
||||
import { useTranslate } from "../i18n"
|
||||
@ -8,19 +7,18 @@ const Container = styled.div`
|
||||
font-size: 90%;
|
||||
line-height: 1.5em;
|
||||
opacity: 0.5;
|
||||
user-select: none;
|
||||
`
|
||||
|
||||
export const ItemDates = memo<{ item: Item }>(({ item }) => {
|
||||
export const ItemDates: React.FC<{ item: Item }> = ({ item }) => {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<Container>
|
||||
<div>
|
||||
{t.label.last_updated}: {new Date(item.updatedAt).toLocaleString()}
|
||||
{t.label_last_updated}: {new Date(item.updatedAt).toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
{t.label.created_at}: {new Date(item.createdAt).toLocaleString()}
|
||||
{t.label_created_at}: {new Date(item.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { memo } from "react"
|
||||
import styled from "@emotion/styled"
|
||||
import type { ItemField, ItemSection } from "opvault.js"
|
||||
import { ErrorBoundary } from "./ErrorBoundary"
|
||||
@ -12,12 +11,11 @@ const Container: React.FC = styled.div`
|
||||
export const FieldTitle: React.FC = styled.div`
|
||||
font-size: 85%;
|
||||
margin-bottom: 3px;
|
||||
user-select: none;
|
||||
`
|
||||
|
||||
export const ItemFieldView = memo<{
|
||||
export const ItemFieldView: React.FC<{
|
||||
field: ItemSection.Any
|
||||
}>(({ field }) => {
|
||||
}> = ({ field }) => {
|
||||
if (field.v == null) {
|
||||
return null
|
||||
}
|
||||
@ -30,15 +28,12 @@ export const ItemFieldView = memo<{
|
||||
</Container>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const hideIds = new Set(["use_desktop", "use_mobile", "use_html"])
|
||||
const hideNames = new Set(["remember"])
|
||||
|
||||
export const ItemDetailsFieldView = memo<{
|
||||
export const ItemDetailsFieldView: React.FC<{
|
||||
field: ItemField
|
||||
}>(({ field }) => {
|
||||
if (field.value == null || hideIds.has(field.id!) || hideNames.has(field.name)) {
|
||||
}> = ({ field }) => {
|
||||
if (field.value == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -50,4 +45,4 @@ export const ItemDetailsFieldView = memo<{
|
||||
</Container>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
@ -4,11 +4,10 @@ import styled from "@emotion/styled"
|
||||
const Container = styled.menu`
|
||||
background-color: #fff;
|
||||
border-radius: 3px;
|
||||
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;
|
||||
box-shadow: #0004 0px 1px 4px;
|
||||
left: 99%;
|
||||
margin-block-start: 0;
|
||||
min-width: 195px;
|
||||
min-width: 120px;
|
||||
padding-inline-start: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@ -34,23 +33,16 @@ const Separator = styled.div`
|
||||
|
||||
const Item = styled.div`
|
||||
cursor: default;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
height: 2.3em;
|
||||
height: 2.5em;
|
||||
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;
|
||||
}
|
||||
|
@ -2,46 +2,24 @@ import styled from "@emotion/styled"
|
||||
import type { ItemSection, ItemField } from "opvault.js"
|
||||
import { FieldType } from "opvault.js"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useTranslate } from "../i18n"
|
||||
import { parseMonthYear } from "../utils"
|
||||
import { BigTextView } from "./BigTextView"
|
||||
import { ErrorBoundary } from "./ErrorBoundary"
|
||||
import { useItemFieldContextMenu } from "./ItemFieldContextMenu"
|
||||
import { toast, ToastType } from "./Toast"
|
||||
|
||||
const Container = styled.div`
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: #6fa9ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`
|
||||
|
||||
export { Container as ClickableContainer }
|
||||
|
||||
function useCopy(text: string) {
|
||||
const t = useTranslate()
|
||||
return useCallback(() => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast({
|
||||
type: ToastType.Secondary,
|
||||
message: t.tips.copied_to_clipboard,
|
||||
})
|
||||
}, [text, t])
|
||||
}
|
||||
|
||||
export { Password as PasswordFieldView }
|
||||
const Container = styled.div``
|
||||
|
||||
const Password: React.FC<{
|
||||
field: Pick<ItemSection.Concealed, "v">
|
||||
field: ItemSection.Concealed
|
||||
}> = ({ field }) => {
|
||||
const t = useTranslate()
|
||||
const [show, setShow] = useState(false)
|
||||
const [bigText, showBigText] = useState(false)
|
||||
|
||||
const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu()
|
||||
const onToggle = useCallback(() => setShow(x => !x), [])
|
||||
const onCopy = useCopy(field.v)
|
||||
const onCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(field.v)
|
||||
}, [field.v])
|
||||
const onOpenBigText = useCallback(() => {
|
||||
showBigText(true)
|
||||
}, [])
|
||||
@ -54,7 +32,6 @@ const Password: React.FC<{
|
||||
<Container
|
||||
onContextMenu={onRightClick}
|
||||
onDoubleClick={() => setShow(x => !x)}
|
||||
onClick={onCopy}
|
||||
style={{
|
||||
fontFamily: "var(--monospace)",
|
||||
...(!show && { userSelect: "none" }),
|
||||
@ -64,11 +41,9 @@ const Password: React.FC<{
|
||||
</Container>
|
||||
{bigText && <BigTextView onClose={onCloseBigText}>{field.v}</BigTextView>}
|
||||
<ContextMenuContainer>
|
||||
<Item onClick={onCopy}>{t.action.copy}</Item>
|
||||
<Item onClick={onToggle}>{show ? t.action.hide : t.action.show}</Item>
|
||||
{!bigText && (
|
||||
<Item onClick={onOpenBigText}>{t.action.show_in_big_characters}</Item>
|
||||
)}
|
||||
<Item onClick={onCopy}>Copier</Item>
|
||||
<Item onClick={onToggle}>{show ? "Cacher" : "Afficher"}</Item>
|
||||
{!bigText && <Item onClick={onOpenBigText}>Afficher en gros caractères</Item>}
|
||||
</ContextMenuContainer>
|
||||
</>
|
||||
)
|
||||
@ -90,13 +65,13 @@ const DateView: React.FC<{ field: ItemSection.Date }> = ({ field }) => {
|
||||
|
||||
const TextView: React.FC<{ value: string }> = ({ value }) => {
|
||||
const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu()
|
||||
const onCopy = useCopy(value)
|
||||
const onCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(value)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container onContextMenu={onRightClick} onClick={onCopy}>
|
||||
{value}
|
||||
</Container>
|
||||
<Container onContextMenu={onRightClick}>{value}</Container>
|
||||
<ContextMenuContainer>
|
||||
<Item onClick={onCopy}>Copier</Item>
|
||||
</ContextMenuContainer>
|
||||
@ -140,13 +115,16 @@ export const ItemFieldValue: React.FC<{
|
||||
export const ItemDetailsFieldValue: React.FC<{
|
||||
field: ItemField
|
||||
}> = ({ field }) => {
|
||||
if (field.type === FieldType.Password || field.designation === "password") {
|
||||
if (
|
||||
field.type === FieldType.Password ||
|
||||
(field.type === FieldType.Text && field.designation === "password")
|
||||
) {
|
||||
return <Password field={{ v: field.value } as any} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<TextView value={field.value!} />
|
||||
<Container>{field.value}</Container>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
@ -1,11 +1,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"
|
||||
|
||||
interface ListProps {
|
||||
items: Item[]
|
||||
@ -19,14 +15,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);
|
||||
}
|
||||
@ -41,58 +36,36 @@ const ItemTitle = styled.div`
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
`
|
||||
|
||||
const ItemDescription = styled.div`
|
||||
font-size: 95%;
|
||||
white-space: nowrap;
|
||||
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()
|
||||
return (
|
||||
<Container>
|
||||
<List>
|
||||
{items.map(item => (
|
||||
<ItemView
|
||||
key={item.uuid}
|
||||
onClick={() => onSelect(item)}
|
||||
className={cx({
|
||||
selected: selected?.uuid === item.uuid,
|
||||
trashed: item.isDeleted,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Icon fill="#FFF" category={item.category} />
|
||||
{!!item.fave && <Favorite />}
|
||||
</div>
|
||||
<div>
|
||||
<ItemTitle>
|
||||
{item.overview.title || <ItemNoTitle>{t.label.no_title}</ItemNoTitle>}
|
||||
</ItemTitle>
|
||||
<ItemDescription className={cx(!item.overview.ainfo && "empty")}>
|
||||
{item.overview.ainfo || "-"}
|
||||
</ItemDescription>
|
||||
</div>
|
||||
</ItemView>
|
||||
))}
|
||||
</List>
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
export const ItemList: React.FC<ListProps> = ({ items, onSelect, selected }) => (
|
||||
<Container>
|
||||
<List>
|
||||
{items.map(item => (
|
||||
<ItemView
|
||||
key={item.uuid}
|
||||
onClick={() => onSelect(item)}
|
||||
className={cx({
|
||||
selected: selected?.uuid === item.uuid,
|
||||
trashed: item.isDeleted,
|
||||
})}
|
||||
>
|
||||
<Icon fill="#FFF" category={item.category} />
|
||||
<div>
|
||||
<ItemTitle>{item.overview.title!}</ItemTitle>
|
||||
<ItemDescription>{item.overview.ainfo}</ItemDescription>
|
||||
</div>
|
||||
</ItemView>
|
||||
))}
|
||||
</List>
|
||||
</Container>
|
||||
)
|
||||
|
@ -1,6 +1,6 @@
|
||||
import styled from "@emotion/styled"
|
||||
import type { Item } from "opvault.js"
|
||||
import { useMemo, memo } from "react"
|
||||
import { useMemo } from "react"
|
||||
import { parseMonthYear } from "../utils"
|
||||
|
||||
const Container = styled.div`
|
||||
@ -12,7 +12,7 @@ const Container = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
export const ItemWarning = memo<{ item: Item }>(({ item }) => {
|
||||
export const ItemWarning: React.FC<{ item: Item }> = ({ item }) => {
|
||||
const isExpired = useMemo(() => {
|
||||
const fields = item.details.sections?.flatMap(x => x.fields ?? [])
|
||||
if (!fields?.length) return false
|
||||
@ -38,4 +38,4 @@ export const ItemWarning = memo<{ item: Item }>(({ item }) => {
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
@ -1,71 +0,0 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { useCallback } from "react"
|
||||
|
||||
const ModalBackground = styled.div`
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(1px);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
`
|
||||
const ModalBackground2 = styled.div`
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
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;
|
||||
margin: 0 auto;
|
||||
`
|
||||
const ModalTitle = styled.div`
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 10px 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
`
|
||||
const ModalContent = styled.div`
|
||||
padding: 15px 20px;
|
||||
`
|
||||
|
||||
export const Modal: React.FC<{
|
||||
show: boolean
|
||||
title: string
|
||||
maxWidth?: number
|
||||
onClose(): void
|
||||
}> = ({ show, children, title, maxWidth = 700, onClose }) => {
|
||||
const onBackgroundClick = useCallback(
|
||||
e => {
|
||||
if (e.currentTarget === e.target) {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
)
|
||||
|
||||
if (!show) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalBackground />
|
||||
<ModalBackground2 onClick={onBackgroundClick}>
|
||||
<ModalContainer style={{ maxWidth }}>
|
||||
<ModalTitle>{title}</ModalTitle>
|
||||
<ModalContent>{children}</ModalContent>
|
||||
</ModalContainer>
|
||||
</ModalBackground2>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
import { memo } from "react"
|
||||
import styled from "@emotion/styled"
|
||||
|
||||
const Container = styled.div`
|
||||
@ -17,8 +16,8 @@ const Title = styled.div`
|
||||
flex-grow: 1;
|
||||
`
|
||||
|
||||
export const TitleBar = memo(() => (
|
||||
export const TitleBar = () => (
|
||||
<Container>
|
||||
<Title>OPVault Viewer</Title>
|
||||
</Container>
|
||||
))
|
||||
)
|
||||
|
@ -1,69 +0,0 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export enum ToastType {
|
||||
Regular = "regular",
|
||||
Primary = "primary",
|
||||
Secondary = "secondary",
|
||||
Success = "success",
|
||||
Danger = "danger",
|
||||
Warning = "warning",
|
||||
Info = "info",
|
||||
}
|
||||
|
||||
interface Message {
|
||||
message: string
|
||||
type: ToastType
|
||||
}
|
||||
interface InternalMessage extends Message {
|
||||
opacity: number
|
||||
id: number
|
||||
}
|
||||
|
||||
export let toast: (message: Message) => void
|
||||
|
||||
const Container = styled.div``
|
||||
const ToastContainer = styled.div`
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 20px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 15%);
|
||||
transition: opacity 1s ease-in-out, bottom 0.3s linear;
|
||||
`
|
||||
|
||||
let lastId = 0
|
||||
|
||||
export const Toast: React.FC = () => {
|
||||
const [list, setList] = useState<InternalMessage[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
toast = message => {
|
||||
const newId = ++lastId
|
||||
setList(list => list.concat({ ...message, id: newId, opacity: 1 }))
|
||||
setTimeout(() => {
|
||||
setList(list => list.map(x => (x.id === newId ? { ...x, opacity: 0 } : x)))
|
||||
}, 1000)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{list.map((message, i, { length }) => (
|
||||
<ToastContainer
|
||||
onTransitionEnd={e => {
|
||||
if (e.propertyName === "opacity") {
|
||||
setList(list => list.filter(x => x.id !== message.id))
|
||||
}
|
||||
}}
|
||||
key={message.id}
|
||||
style={{ opacity: message.opacity, bottom: 40 * (length - i) - 10 }}
|
||||
className={`color-${message.type}`}
|
||||
>
|
||||
{message.message}
|
||||
</ToastContainer>
|
||||
))}
|
||||
</Container>
|
||||
)
|
||||
}
|
22
packages/web/src/components/VaultPicker.tsx
Normal file
22
packages/web/src/components/VaultPicker.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
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,7 +2,6 @@
|
||||
// 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.
|
||||
@ -14,7 +13,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
9
packages/web/src/electron/ipc-types.d.ts
vendored
@ -1,9 +0,0 @@
|
||||
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>
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
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,5 +1,4 @@
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
import { contextBridge } from "electron"
|
||||
import { nodeAdapter } from "opvault.js/src/adapters/node"
|
||||
|
||||
contextBridge.exposeInMainWorld("ipcRenderer", {
|
||||
invoke: ipcRenderer.invoke,
|
||||
})
|
||||
contextBridge.exposeInMainWorld("nodeAdapter", nodeAdapter)
|
||||
|
@ -1,25 +1,18 @@
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { createContext, memo, useContext, useEffect, useMemo, useState } from "react"
|
||||
import texts from "./texts.yml"
|
||||
import { get, set, Key } from "../utils/localStorage"
|
||||
|
||||
const categories = Object.keys(texts)
|
||||
type Keys = keyof typeof texts
|
||||
|
||||
const ALLOWED = new Set(["en", "fr", "ja"])
|
||||
const SKIP_ITALIC = new Set(["zh_CN", "zh_TW", "ko", "ja"])
|
||||
const ALLOWED = new Set(["en", "fr"])
|
||||
const LOCALSTORAGE_KEY = "preferred-locale"
|
||||
|
||||
function getLocaleFromStorage() {
|
||||
const key = get(Key.PREFERRED_LOCALE)
|
||||
if (key && ALLOWED.has(key)) {
|
||||
return key
|
||||
}
|
||||
try {
|
||||
const key = localStorage.getItem(LOCALSTORAGE_KEY)
|
||||
if (key && ALLOWED.has(key)) {
|
||||
return key
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getNavigatorLocale() {
|
||||
@ -45,42 +38,35 @@ export const useLocaleContext = () => useContext(LocaleContext)
|
||||
|
||||
export function useTranslate() {
|
||||
const { locale } = useContext(LocaleContext)
|
||||
const getter = useCallback(
|
||||
(category: string, key: string) => {
|
||||
const obj = (texts as any)[category]
|
||||
if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!Object.prototype.hasOwnProperty.call(obj, key)
|
||||
) {
|
||||
throw new Error(`t.${key} does not exist.`)
|
||||
}
|
||||
return obj[key][locale]
|
||||
},
|
||||
const t = useMemo(
|
||||
() =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, p: string) {
|
||||
if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!Object.prototype.hasOwnProperty.call(texts, p)
|
||||
) {
|
||||
throw new Error(`t.${p} does not exist.`)
|
||||
}
|
||||
return (texts as any)[p][locale]
|
||||
},
|
||||
}
|
||||
) as {
|
||||
[key in Keys]: string
|
||||
},
|
||||
[locale]
|
||||
)
|
||||
|
||||
const t: {
|
||||
[category in keyof typeof texts]: {
|
||||
[key in keyof typeof texts[category]]: string
|
||||
}
|
||||
} = useMemo(
|
||||
(): any =>
|
||||
Object.fromEntries(
|
||||
categories.map(category => [
|
||||
category,
|
||||
new Proxy({}, { get: (_, p: string) => getter(category, p) }),
|
||||
])
|
||||
),
|
||||
[getter]
|
||||
)
|
||||
return t
|
||||
}
|
||||
|
||||
export const LocaleContextProvider = memo(({ children }) => {
|
||||
const [locale, setLocale] = useState(getEnvLocale)
|
||||
useEffect(() => {
|
||||
set(Key.PREFERRED_LOCALE, locale)
|
||||
document.documentElement.lang = locale
|
||||
try {
|
||||
localStorage.setItem(LOCALSTORAGE_KEY, locale)
|
||||
} catch {}
|
||||
}, [locale])
|
||||
const value = useMemo(() => ({ locale, setLocale }), [locale])
|
||||
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
|
||||
|
@ -1,250 +1,28 @@
|
||||
# /* spellchecker: disable */
|
||||
label:
|
||||
app_name:
|
||||
en: OPVault Viewer
|
||||
fr: Lecteur de coffre OPVault
|
||||
ja: OPVault ビューワー
|
||||
label_choose_a_vault:
|
||||
en: Pick a vault here.
|
||||
fr: Choisir un coffre ici.
|
||||
|
||||
choose_a_vault:
|
||||
en: Pick a vault
|
||||
fr: Choisir un coffre
|
||||
ja: 保管庫を選ぶ
|
||||
label_no_vault_selected:
|
||||
en: No vault is selected.
|
||||
fr: Aucun coffre n’est sélectionné.
|
||||
|
||||
no_vault_selected:
|
||||
en: No vault is selected.
|
||||
fr: Aucun coffre n’est sélectionné.
|
||||
ja: 選択した保管庫はありません。
|
||||
label_last_updated:
|
||||
en: Last Updated
|
||||
fr: Dernière modification
|
||||
|
||||
last_updated:
|
||||
en: Last Updated
|
||||
fr: Dernière modification
|
||||
ja: 更新日時
|
||||
label_created_at:
|
||||
en: Created At
|
||||
fr: Créé
|
||||
|
||||
created_at:
|
||||
en: Created At
|
||||
fr: Créé
|
||||
ja: 作成日時
|
||||
noun_vault:
|
||||
en: vault
|
||||
fr: coffre
|
||||
|
||||
password_placeholder:
|
||||
en: Master Password
|
||||
fr: Mot de passe principal
|
||||
ja: マスターパスワード
|
||||
action_lock:
|
||||
en: Lock
|
||||
fr: Vérouiller
|
||||
|
||||
username:
|
||||
en: Username
|
||||
fr: Nom d’utilisateur
|
||||
ja: ユーザー名
|
||||
|
||||
password:
|
||||
en: Password
|
||||
fr: Mot de passe
|
||||
ja: パスワード
|
||||
|
||||
no_title:
|
||||
en: Untitled
|
||||
fr: Sans titre
|
||||
ja: 無題
|
||||
|
||||
settings:
|
||||
en: Settings
|
||||
fr: Préférences
|
||||
ja: 設定
|
||||
|
||||
language:
|
||||
en: Language
|
||||
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
|
||||
fr: Trier par nom
|
||||
ja: 名前順
|
||||
|
||||
sort_by_created_at:
|
||||
en: Sort by date created
|
||||
fr: Trier par date de création
|
||||
ja: 作成日時順
|
||||
|
||||
sort_by_updated_at:
|
||||
en: Sort by date modified
|
||||
fr: Trier par date de modification
|
||||
ja: 更新日時順
|
||||
|
||||
enable_autolock:
|
||||
en: Auto Lock
|
||||
fr: Verrouillage automatique
|
||||
ja: 自動ロック
|
||||
|
||||
noun:
|
||||
vault:
|
||||
en: vault
|
||||
fr: coffre
|
||||
ja: 保管庫
|
||||
|
||||
tags:
|
||||
en: tags
|
||||
fr: mots-clés
|
||||
ja: キーワード
|
||||
|
||||
seconds:
|
||||
en: seconds
|
||||
fr: secondes
|
||||
ja: 秒
|
||||
|
||||
action:
|
||||
lock:
|
||||
en: Lock
|
||||
fr: Vérouiller
|
||||
ja: ロック
|
||||
|
||||
unlock:
|
||||
en: Unlock
|
||||
fr: Déverouiller
|
||||
ja: ロック解除
|
||||
|
||||
copy:
|
||||
en: Copy
|
||||
fr: Copier
|
||||
ja: コピー
|
||||
|
||||
hide:
|
||||
en: Hide
|
||||
fr: Cacher
|
||||
ja: 非表示
|
||||
|
||||
show:
|
||||
en: Show
|
||||
fr: Afficher
|
||||
ja: 表示
|
||||
|
||||
show_in_big_characters:
|
||||
en: Show in large characters
|
||||
fr: Afficher en gros caractères
|
||||
ja: 大きく表示
|
||||
|
||||
go_back:
|
||||
en: Back
|
||||
fr: Revenir
|
||||
ja: 前に戻る
|
||||
|
||||
go_forward:
|
||||
en: Forward
|
||||
fr: Avancer
|
||||
ja: 次に進む
|
||||
|
||||
clear_history:
|
||||
en: Clear history
|
||||
fr: Effacer l’historique
|
||||
ja: 閲覧履歴を消す
|
||||
|
||||
tips:
|
||||
automatically_lock_after_inactivity:
|
||||
en: Automatically lock after inactivity
|
||||
fr: Verouiller automatiquement après un temps d’inactivité
|
||||
ja: 一定時間使わないときに自動的にロックする
|
||||
|
||||
copied_to_clipboard:
|
||||
en: Copied to clipboard
|
||||
fr: Copié dans le presse-papier
|
||||
ja: クリップボードへコピーしました
|
||||
action_unlock:
|
||||
en: Unlock
|
||||
fr: Déverouiller
|
||||
|
@ -10,50 +10,36 @@ body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen",
|
||||
"Cantarell", "Droid Sans", "Helvetica Neue", "Noto Sans CJK JP", sans-serif;
|
||||
}
|
||||
:root {
|
||||
--page-background: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen", "Ubuntu",
|
||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
|
||||
--color: #000;
|
||||
--titlebar-height: 46px;
|
||||
--titlebar-height: 0px;
|
||||
--label-background: #ddd;
|
||||
--selected-background: #d5d5d5;
|
||||
--selected-background: #c9c9c9;
|
||||
--hover-background: #ddd;
|
||||
--border-color: #e3e3e3;
|
||||
--monospace: D2Coding, "source-code-pro", Menlo, Monaco, Consolas, "Courier New",
|
||||
--monospace: D2Coding, source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root.mac {
|
||||
--page-background: #f7f7f7;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
background-color: var(--page-background);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
body {
|
||||
color: #fff;
|
||||
--color: #fff;
|
||||
--label-background: #353535;
|
||||
--selected-background: #353535;
|
||||
--border-color: #333;
|
||||
--selected-background: #15539e;
|
||||
--hover-background: #222;
|
||||
--page-background: #292929;
|
||||
}
|
||||
body {
|
||||
color: #fff;
|
||||
#root {
|
||||
background-color: #292929;
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,51 +52,24 @@ input {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
@mixin input {
|
||||
input[type="search"],
|
||||
input[type="input"],
|
||||
input[type="password"] {
|
||||
@include scheme(background-color, #fff, #2d2d2d);
|
||||
border-radius: 6px;
|
||||
border: 1px solid #fff;
|
||||
border: 1px solid;
|
||||
@include scheme(border-color, #cdc7c2, #1b1b1b);
|
||||
color: inherit;
|
||||
outline: none;
|
||||
padding: 7px 8px;
|
||||
transition: 0.1s;
|
||||
&:focus {
|
||||
@include scheme(border-color, #3584e480, #15539e);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="search"],
|
||||
input[type="input"],
|
||||
input[type="number"],
|
||||
input[type="password"] {
|
||||
@include input;
|
||||
border-radius: 6px;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
padding: 7px 8px;
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
input[type="checkbox" i] {
|
||||
@include input;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05),
|
||||
inset 0px -15px 10px -12px rgba(0, 0, 0, 0.05);
|
||||
padding: 9px;
|
||||
border-radius: 3px;
|
||||
appearance: none;
|
||||
position: relative;
|
||||
&:checked:after {
|
||||
content: "\2714";
|
||||
font-size: 15px;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 3px;
|
||||
color: var(--color);
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
select,
|
||||
.button {
|
||||
select {
|
||||
@include scheme(background-color, #f6f5f4, #333);
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
@ -138,33 +97,17 @@ select {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
// #region color
|
||||
.color-primary,
|
||||
.color-secondary,
|
||||
.color-info,
|
||||
.color-danger {
|
||||
@include scheme(color, #fff, #fafafa);
|
||||
::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
}
|
||||
.color-success,
|
||||
.color-warning {
|
||||
@include scheme(color, #000, #111);
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.color-primary {
|
||||
@include scheme(background-color, #0b5ed7, #375a7f);
|
||||
::-webkit-scrollbar-thumb {
|
||||
@include scheme(background, #8883, #6663);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.color-secondary {
|
||||
@include scheme(background-color, #6c757d, #626262);
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
transition: 0.1s;
|
||||
@include scheme(background, #ddd, #555);
|
||||
}
|
||||
.color-success {
|
||||
@include scheme(background-color, #198754, #00bc8c);
|
||||
}
|
||||
.color-info {
|
||||
@include scheme(background-color, #0dcaf0, #17a2b8);
|
||||
}
|
||||
.color-warning {
|
||||
@include scheme(background-color, #ffc107, #f39c12);
|
||||
}
|
||||
.color-danger {
|
||||
@include scheme(background-color, #dc3545, #e74c3c);
|
||||
}
|
||||
// #endregion
|
||||
|
@ -2,23 +2,14 @@ import React from "react"
|
||||
import { render } from "react-dom"
|
||||
import { App } from "./App"
|
||||
import { LocaleContextProvider } from "./i18n"
|
||||
import { SideEffect } from "./SideEffect"
|
||||
import { Toast } from "./components/Toast"
|
||||
import "./index.scss"
|
||||
|
||||
if (navigator.platform === "MacIntel") {
|
||||
document.documentElement.classList.add("mac")
|
||||
}
|
||||
|
||||
const Root: React.FC = () => (
|
||||
render(
|
||||
<React.StrictMode>
|
||||
{/* <TitleBar /> */}
|
||||
<LocaleContextProvider>
|
||||
<SideEffect />
|
||||
<App />
|
||||
<Toast />
|
||||
</LocaleContextProvider>
|
||||
</React.StrictMode>
|
||||
</React.StrictMode>,
|
||||
document.getElementById("root")
|
||||
)
|
||||
|
||||
render(<Root />, document.getElementById("app"))
|
||||
|
3
packages/web/src/modules.d.ts
vendored
3
packages/web/src/modules.d.ts
vendored
@ -1,3 +0,0 @@
|
||||
declare module "react-idle-timer/modern" {
|
||||
export * from "react-idle-timer/dist/modern"
|
||||
}
|
23
packages/web/src/pages/PickOPVault.tsx
Normal file
23
packages/web/src/pages/PickOPVault.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
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>
|
||||
)
|
||||
}
|
61
packages/web/src/pages/Unlock.tsx
Normal file
61
packages/web/src/pages/Unlock.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import type { OnePassword } from "opvault.js"
|
||||
import styled from "@emotion/styled"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useTranslate } from "../i18n"
|
||||
|
||||
const Container = styled.form`
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
export const Unlock: React.FC<{
|
||||
instance: OnePassword
|
||||
onUnlock(profile: string, password: string): void
|
||||
}> = ({ onUnlock, 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()
|
||||
|
||||
if (!profile) return
|
||||
onUnlock(profile, password)
|
||||
setPassword("")
|
||||
},
|
||||
[onUnlock, profile, password]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
instance.getProfileNames().then(profiles => {
|
||||
setProfiles(profiles)
|
||||
setProfile(profiles[0])
|
||||
})
|
||||
}, [instance])
|
||||
|
||||
return (
|
||||
<Container onSubmit={unlock}>
|
||||
<div>
|
||||
<select value={profile} onChange={e => setProfile(e.currentTarget.value)}>
|
||||
{profiles.map(p => (
|
||||
<option key={p} value={p}>
|
||||
{t.noun_vault}: {p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ margin: "10px 0" }}>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={!profile || !password}>
|
||||
{t.action_unlock}
|
||||
</button>
|
||||
</Container>
|
||||
)
|
||||
}
|
@ -1,103 +1,134 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import type { Vault, Item } from "opvault.js"
|
||||
import { AiOutlineStar } from "react-icons/ai"
|
||||
import { FiLock } from "react-icons/fi"
|
||||
import { Si1Password } from "react-icons/si"
|
||||
import { BsGear } from "react-icons/bs"
|
||||
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 { Settings } from "../settings"
|
||||
import { FilteredVaultView } from "../components/FilteredVaultView"
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
height: calc(100vh - var(--titlebar-height));
|
||||
`
|
||||
const TabContainer = styled.div`
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
const ListContainer = styled.div`
|
||||
width: 300px;
|
||||
margin-right: 10px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #202020;
|
||||
}
|
||||
`
|
||||
const ItemContainer = styled.div`
|
||||
width: calc(100% - 300px);
|
||||
overflow: hidden;
|
||||
padding-bottom: 5px;
|
||||
width: 54px;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #222;
|
||||
border-right-color: transparent;
|
||||
}
|
||||
&&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
const TabButton = styled.button<{ active?: boolean }>`
|
||||
align-items: center;
|
||||
background: ${p => (p.active ? "var(--selected-background)" : "transparent")};
|
||||
border-radius: ${p => (p.active ? 0 : 3)}px;
|
||||
border: transparent;
|
||||
box-shadow: none;
|
||||
display: inline-flex;
|
||||
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;
|
||||
}
|
||||
const SearchContainer = styled.div`
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
position: relative;
|
||||
`
|
||||
const TabContainerMain = styled.div`
|
||||
flex-grow: 1;
|
||||
const SortContainer = styled.div`
|
||||
margin: 10px 10px;
|
||||
`
|
||||
const SearchInput = styled.input`
|
||||
--margin: 10px;
|
||||
width: calc(100% - var(--margin) * 2);
|
||||
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) => a.createdAt - b.createdAt
|
||||
case SortBy.UpdatedAt:
|
||||
return (a, b) => a.updatedAt - b.updatedAt
|
||||
}
|
||||
}, [sortBy])
|
||||
|
||||
const sortedItem = useMemo(() => items.slice().sort(compareFn), [items, compareFn])
|
||||
|
||||
useEffect(() => {
|
||||
setItem(undefined)
|
||||
arrayFrom(vault.values()).then(setItems)
|
||||
}, [vault])
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
sortedItem
|
||||
.filter(x => x.category !== Category.Tombstone)
|
||||
.filter(
|
||||
search
|
||||
? x =>
|
||||
stringCompare(search, x.overview.title) ||
|
||||
stringCompare(search, x.overview.ainfo)
|
||||
: () => true
|
||||
),
|
||||
[sortedItem, search]
|
||||
)
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TabContainer>
|
||||
<TabContainerMain>
|
||||
<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 />
|
||||
</TabButton>
|
||||
<TabButton onClick={() => setShowSettings(true)} title={t.label.settings}>
|
||||
<BsGear />
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
|
||||
{tab === Tab.All ? (
|
||||
<FilteredVaultView items={items} />
|
||||
) : tab === Tab.Favorites ? (
|
||||
<FavoriteItemsView items={items} />
|
||||
) : null}
|
||||
|
||||
<Settings show={showSettings} onHide={() => setShowSettings(false)} />
|
||||
<ListContainer>
|
||||
<div
|
||||
style={{
|
||||
margin: "10px 10px",
|
||||
}}
|
||||
>
|
||||
<button onClick={onLock}>{t.action_lock}</button>
|
||||
</div>
|
||||
<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}>Sort by Name</option>
|
||||
<option value={SortBy.CreatedAt}>Sort by Created Time</option>
|
||||
<option value={SortBy.UpdatedAt}>Sort by Updated Time</option>
|
||||
</select>
|
||||
</SortContainer>
|
||||
<ItemList items={filtered} onSelect={setItem} selected={item} />
|
||||
</ListContainer>
|
||||
<ItemContainer>{item && <ItemView item={item} />}</ItemContainer>
|
||||
</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) {
|
||||
@ -106,7 +137,8 @@ async function arrayFrom<T>(generator: AsyncGenerator<T, void, unknown>) {
|
||||
return list
|
||||
}
|
||||
|
||||
enum Tab {
|
||||
All,
|
||||
Favorites,
|
||||
function stringCompare(search: string, source?: string) {
|
||||
if (!search) return true
|
||||
if (!source) return false
|
||||
return source.toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
||||
}
|
||||
|
@ -1,148 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -1,148 +0,0 @@
|
||||
import type { OnePassword } from "opvault.js"
|
||||
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"
|
||||
|
||||
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;
|
||||
`
|
||||
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, vaultPath }) => {
|
||||
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()
|
||||
|
||||
if (!profile) return
|
||||
onUnlock(profile, password)
|
||||
setPassword("")
|
||||
},
|
||||
[onUnlock, profile, password]
|
||||
)
|
||||
|
||||
const onKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
unlock()
|
||||
}
|
||||
},
|
||||
[unlock]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
instance.getProfileNames().then(profiles => {
|
||||
setProfiles(profiles)
|
||||
setProfile(profiles[0])
|
||||
})
|
||||
}, [instance])
|
||||
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<VaultPath>{vaultPath}</VaultPath>
|
||||
</Container>
|
||||
)
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import type { Vault } from "opvault.js"
|
||||
import { OnePassword } from "opvault.js"
|
||||
import { Unlock } from "./Unlock"
|
||||
import { electronAdapter } from "../../utils/ipc-adapter"
|
||||
import { get, remove, set, Key } from "../../utils/localStorage"
|
||||
import { PickOPVault } from "./Picker"
|
||||
|
||||
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, setVault]
|
||||
)
|
||||
|
||||
const clearInstance = useCallback(() => {
|
||||
setVaultPath("")
|
||||
setInstance(undefined)
|
||||
}, [setInstance])
|
||||
|
||||
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, setInstance])
|
||||
|
||||
if (!instance) {
|
||||
return <PickOPVault setPath={setVaultPath} />
|
||||
}
|
||||
if (!vault) {
|
||||
return (
|
||||
<Unlock
|
||||
vaultPath={vaultPath}
|
||||
onReturn={clearInstance}
|
||||
instance={instance}
|
||||
onUnlock={unlock}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { Modal } from "../components/Modal"
|
||||
import { useLocaleContext, useTranslate } from "../i18n"
|
||||
import { Key, useStorage } from "../utils/localStorage"
|
||||
|
||||
const FormItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 13px;
|
||||
`
|
||||
const FormLabel = styled.div`
|
||||
width: 120px;
|
||||
`
|
||||
const FormValue = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
min-width: 200px;
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
const Checkbox = styled.input`
|
||||
margin-left: 0;
|
||||
margin-right: 8px;
|
||||
`
|
||||
|
||||
const GhostLabel = styled.div`
|
||||
opacity: 0.5;
|
||||
position: absolute;
|
||||
left: 37px;
|
||||
pointer-events: none;
|
||||
`
|
||||
|
||||
export const Settings: React.FC<{
|
||||
show: boolean
|
||||
onHide(): void
|
||||
}> = ({ show, onHide }) => {
|
||||
const { locale, setLocale } = useLocaleContext()
|
||||
const t = useTranslate()
|
||||
|
||||
const [enableAutoLock, setEnableAutoLock] = useStorage(Key.ENABLE_AUTO_LOCK)
|
||||
const [autolockAfter, setAutolockAfter] = useStorage(Key.AUTO_LOCK_AFTER)
|
||||
|
||||
return (
|
||||
<Modal show={show} title={t.label.settings} onClose={onHide}>
|
||||
<FormItem>
|
||||
<FormLabel>{t.label.language}</FormLabel>
|
||||
<FormValue>
|
||||
<select
|
||||
title={t.label.language}
|
||||
value={locale}
|
||||
onChange={e => setLocale(e.currentTarget.value)}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="ja">日本語</option>
|
||||
</select>
|
||||
</FormValue>
|
||||
</FormItem>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel title={t.tips.automatically_lock_after_inactivity}>
|
||||
{t.options.enable_autolock}
|
||||
</FormLabel>
|
||||
<FormValue>
|
||||
<Checkbox
|
||||
type="checkbox"
|
||||
checked={enableAutoLock}
|
||||
onChange={e => setEnableAutoLock(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={autolockAfter}
|
||||
onChange={e => setAutolockAfter(e.target.valueAsNumber)}
|
||||
disabled={!enableAutoLock}
|
||||
/>
|
||||
<GhostLabel>
|
||||
<span style={{ opacity: 0 }}>{autolockAfter} </span>
|
||||
{t.noun.seconds}
|
||||
</GhostLabel>
|
||||
</FormValue>
|
||||
</FormItem>
|
||||
</Modal>
|
||||
)
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
import { css } from "@emotion/css"
|
||||
import styled from "@emotion/styled"
|
||||
|
||||
export const ItemNoTitle = styled.span`
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
[lang^="zh"],
|
||||
[lang="ko"],
|
||||
[lang="ja"] & {
|
||||
font-style: normal;
|
||||
}
|
||||
`
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
@ -1,31 +0,0 @@
|
||||
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
|
||||
),
|
||||
})
|
@ -1,76 +0,0 @@
|
||||
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",
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const events = new Map(Object.values(Key).map(key => [key, new Set()])) as {
|
||||
get<K extends Key>(key: K): Set<(value: StoredData[Key]) => void>
|
||||
}
|
||||
|
||||
export function useStorage<K extends Key>(key: K) {
|
||||
const [state, setState] = useState(get(key)!)
|
||||
useEffect(() => {
|
||||
events.get(key).add(setState as any)
|
||||
return () => {
|
||||
events.get(key).delete(setState as any)
|
||||
}
|
||||
}, [key])
|
||||
const setState2 = useCallback(
|
||||
(value: ((value: StoredData[K]) => StoredData[K]) | StoredData[K]) => {
|
||||
set(key, value)
|
||||
},
|
||||
[key]
|
||||
)
|
||||
|
||||
return [state, setState2] as const
|
||||
}
|
||||
|
||||
export function get<K extends Key>(key: K): StoredData[K] | undefined {
|
||||
try {
|
||||
const value = localStorage.getItem(key)
|
||||
return value == null ? undefined : (JSON.parse(value!) as StoredData[K])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
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 as StoredData[K]))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
export function remove(key: Key) {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const defaults: typeof set = (key, value) => {
|
||||
if (!(key in localStorage)) {
|
||||
set(key, value)
|
||||
}
|
||||
}
|
||||
defaults(Key.ENABLE_AUTO_LOCK, true)
|
||||
defaults(Key.AUTO_LOCK_AFTER, 180)
|
||||
defaults(Key.RECENTLY_OPENED_VAULTS, [])
|
@ -1,18 +0,0 @@
|
||||
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
|
||||
}
|
4
packages/web/src/vite-env.d.ts
vendored
4
packages/web/src/vite-env.d.ts
vendored
@ -1,5 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Array<T> {
|
||||
filter(predicate: BooleanConstructor): Exclude<T, null | undefined | 0 | "" | false>[]
|
||||
}
|
||||
|
@ -1,12 +1,11 @@
|
||||
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: [babel(), react(), yaml()],
|
||||
plugins: [react(), yaml()],
|
||||
define: {
|
||||
global: "globalThis",
|
||||
"process.browser": "true",
|
||||
@ -14,9 +13,6 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
outDir: "dist/web",
|
||||
rollupOptions: {
|
||||
external: ["fs", ""],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
3123
pnpm-lock.yaml
generated
3123
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -2,8 +2,8 @@ import { resolve } from "path";
|
||||
import { describe, it, beforeEach } from "mocha";
|
||||
import { expect } from "chai";
|
||||
|
||||
import type { Vault } from "../packages/opvault.js/src/index";
|
||||
import { OnePassword } from "../packages/opvault.js/src/index";
|
||||
import type { Vault } from "../packages/opvault.js/index";
|
||||
import { OnePassword } from "../packages/opvault.js/index";
|
||||
|
||||
describe("OnePassword", () => {
|
||||
const freddy = resolve(__dirname, "../freddy-2013-12-04.opvault");
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { describe, it } from "mocha";
|
||||
import { expect } from "chai";
|
||||
|
||||
import { WeakValueMap } from "../packages/opvault.js/src/weakMap";
|
||||
import { WeakValueMap } from "../packages/opvault.js/weakMap";
|
||||
|
||||
declare const gc: () => void;
|
||||
|
||||
|
Reference in New Issue
Block a user