Compare commits
25 Commits
1.0.0-beta
...
1.0.0-beta
Author | SHA1 | Date | |
---|---|---|---|
c41ff54c89 | |||
c5ba4c69d2 | |||
d8f2cddb74 | |||
5883adc2c1 | |||
82327da031 | |||
7362222f16 | |||
e16202f8b2 | |||
bf5bdd1f72 | |||
298482f70e | |||
8f9ec73caf | |||
bdd46a530c | |||
b4b21561ed | |||
eb27e81d68 | |||
06e29eaba1 | |||
84c4a55073 | |||
8fdf6e6e7b | |||
674e7ac689 | |||
57d3a5056a | |||
3a9e4e1e3d | |||
69cd8e3ee1 | |||
fbf3c9b1bb | |||
7ee6990be1 | |||
d2ae4be194 | |||
e9b07374e5 | |||
fe926be0a6 |
BIN
.github/screenshot.png
vendored
Normal file
BIN
.github/screenshot.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 380 KiB |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
|
drafts
|
||||||
node_modules
|
node_modules
|
||||||
mochawesome-report
|
mochawesome-report
|
||||||
lib
|
lib
|
||||||
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
|
"cSpell.words": ["autolock"],
|
||||||
"cSpell.ignorePaths": [
|
"cSpell.ignorePaths": [
|
||||||
"**/package-lock.json",
|
"**/package-lock.json",
|
||||||
"**/node_modules/**",
|
"**/node_modules/**",
|
||||||
@ -9,4 +10,4 @@
|
|||||||
".vscode-insiders",
|
".vscode-insiders",
|
||||||
"i18n.json"
|
"i18n.json"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
22
README.md
22
README.md
@ -1,5 +1,27 @@
|
|||||||
# opvault.js
|
# 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
|
## Test
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
44
package.json
44
package.json
@ -4,45 +4,47 @@
|
|||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"repository": "https://git.aet.ac/aet/opvault.js.git",
|
"repository": "https://git.aet.ac/aet/opvault.js.git",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"design": "marked -o design.html < design.md",
|
"design": "marked -o design.html < design.md",
|
||||||
"test": "node --expose-gc node_modules/mocha/bin/_mocha test/**/*.test.ts",
|
"test": "node --expose-gc node_modules/mocha/bin/_mocha test/**/*.test.ts",
|
||||||
"repl": "node -r ts-node/register/transpile-only src/repl.ts"
|
"repl": "node -r ts-node/register/transpile-only src/repl.ts",
|
||||||
|
"dev": "cd packages/web && yarn dev",
|
||||||
|
"bundle": "cd packages/web && yarn bundle"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chai": "^4.2.22",
|
"@types/chai": "^4.3.0",
|
||||||
"@types/chai-as-promised": "^7.1.4",
|
"@types/chai-as-promised": "^7.1.4",
|
||||||
"@types/fs-extra": "^9.0.13",
|
"@types/fs-extra": "^9.0.13",
|
||||||
"@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768",
|
"@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768",
|
||||||
"@types/node": "^16.10.3",
|
"@types/node": "^17.0.6",
|
||||||
"@types/sinon": "^10.0.4",
|
"@types/sinon": "^10.0.6",
|
||||||
"@types/sinon-chai": "^3.2.5",
|
"@types/sinon-chai": "^3.2.8",
|
||||||
"@types/wicg-file-system-access": "^2020.9.4",
|
"@types/wicg-file-system-access": "^2020.9.4",
|
||||||
"@typescript-eslint/eslint-plugin": "4.33.0",
|
"@typescript-eslint/eslint-plugin": "5.8.1",
|
||||||
"@typescript-eslint/parser": "4.33.0",
|
"@typescript-eslint/parser": "5.8.1",
|
||||||
"chai": "^4.3.4",
|
"chai": "^4.3.4",
|
||||||
"chai-as-promised": "^7.1.1",
|
"chai-as-promised": "^7.1.1",
|
||||||
"chalk": "^4.1.2",
|
"chalk": "^4.1.2",
|
||||||
"eslint": "7.32.0",
|
"eslint": "8.6.0",
|
||||||
"eslint-config-prettier": "8.3.0",
|
"eslint-config-prettier": "8.3.0",
|
||||||
"eslint-import-resolver-typescript": "2.5.0",
|
"eslint-import-resolver-typescript": "2.5.0",
|
||||||
"eslint-plugin-import": "2.24.2",
|
"eslint-plugin-import": "2.25.3",
|
||||||
"eslint-plugin-react": "7.26.1",
|
"eslint-plugin-react": "7.28.0",
|
||||||
"eslint-plugin-react-hooks": "4.2.0",
|
"eslint-plugin-react-hooks": "4.3.0",
|
||||||
"fs-extra": "^10.0.0",
|
"fs-extra": "^10.0.0",
|
||||||
"marked": "^3.0.8",
|
"marked": "^4.0.8",
|
||||||
"mocha": "^9.1.2",
|
"mocha": "^9.1.3",
|
||||||
"mochawesome": "^6.3.0",
|
"mochawesome": "^7.0.1",
|
||||||
"prettier": "^2.4.1",
|
"prettier": "^2.5.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"sass": "^1.43.2",
|
"sass": "^1.45.2",
|
||||||
"sinon": "^11.1.2",
|
"sinon": "^12.0.1",
|
||||||
"sinon-chai": "^3.7.0",
|
"sinon-chai": "^3.7.0",
|
||||||
"ts-node": "^10.2.1",
|
"tslib": "^2.3.1",
|
||||||
"tsconfig-paths": "^3.11.0",
|
"ts-node": "^10.4.0",
|
||||||
"typescript": "^4.4.3"
|
"tsconfig-paths": "^3.12.0",
|
||||||
|
"typescript": "^4.5.4"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"arrowParens": "avoid",
|
"arrowParens": "avoid",
|
||||||
|
6
packages/adapters/package.json
Normal file
6
packages/adapters/package.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "opvault-adapters",
|
||||||
|
"dependencies": {
|
||||||
|
"opvault.js": "*"
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import { Buffer } from "buffer"
|
import { Buffer } from "buffer"
|
||||||
import type { IAdapter, IFileSystem } from "./index"
|
import type { IAdapter, IFileSystem } from "opvault.js/src/adapter"
|
||||||
|
|
||||||
function normalize(path: string) {
|
function normalize(path: string) {
|
||||||
return path.replace(/^\//, "")
|
return path.replace(/^\//, "")
|
@ -1,5 +1,5 @@
|
|||||||
import { Buffer } from "buffer"
|
import { Buffer } from "buffer"
|
||||||
import type { IAdapter, IFileSystem } from "./index"
|
import type { IAdapter, IFileSystem } from "opvault.js/src/adapter"
|
||||||
|
|
||||||
export class FileSystem implements IFileSystem {
|
export class FileSystem implements IFileSystem {
|
||||||
private paths = new Set<string>()
|
private paths = new Set<string>()
|
@ -1,184 +0,0 @@
|
|||||||
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.
|
|
9
packages/opvault.js/assets/doc-theme.css
Normal file
9
packages/opvault.js/assets/doc-theme.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
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",
|
"version": "0.0.1",
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rollup -c; cp src/adapters/index.d.ts lib/adapters/; prettier --write lib >/dev/null",
|
"build": "rollup -c; prettier --write lib >/dev/null",
|
||||||
"build:docs": "typedoc --out docs src/index.ts --excludePrivate"
|
"build:docs": "typedoc --out docs src/index.ts --excludePrivate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -15,9 +15,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-json": "^4.1.0",
|
"@rollup/plugin-json": "^4.1.0",
|
||||||
"@rollup/plugin-replace": "^3.0.0",
|
"@rollup/plugin-replace": "^3.0.0",
|
||||||
"prettier": "^2.4.1",
|
"prettier": "^2.5.1",
|
||||||
"rollup": "^2.58.0",
|
"rollup": "^2.61.1",
|
||||||
"rollup-plugin-ts": "^1.4.7",
|
"rollup-plugin-ts": "^2.0.4",
|
||||||
"typedoc": "^0.22.7"
|
"typedoc": "^0.22.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,6 @@ import { dependencies } from "./package.json"
|
|||||||
export default () => ({
|
export default () => ({
|
||||||
input: {
|
input: {
|
||||||
index: "./src/index.ts",
|
index: "./src/index.ts",
|
||||||
"adapters/node": "./src/adapters/node.ts",
|
|
||||||
},
|
},
|
||||||
external: builtinModules.concat(Object.keys(dependencies)),
|
external: builtinModules.concat(Object.keys(dependencies)),
|
||||||
output: {
|
output: {
|
||||||
@ -22,6 +21,8 @@ export default () => ({
|
|||||||
preventAssignment: true,
|
preventAssignment: true,
|
||||||
values: {
|
values: {
|
||||||
"process.env.NODE_ENV": '"production"',
|
"process.env.NODE_ENV": '"production"',
|
||||||
|
'require("./adapter").nodeAdapter':
|
||||||
|
'import("./adapter").then(x => x.nodeAdapter)',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { promises as fs, existsSync } from "fs"
|
||||||
|
import { webcrypto } from "crypto"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An object that implements basic file system functionalities.
|
* An object that implements basic file system functionalities.
|
||||||
*/
|
*/
|
||||||
@ -53,3 +56,19 @@ export interface IAdapter {
|
|||||||
*/
|
*/
|
||||||
subtle: SubtleCrypto
|
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,
|
||||||
|
}
|
@ -1,20 +0,0 @@
|
|||||||
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,12 +1,12 @@
|
|||||||
import { Buffer } from "buffer"
|
import { Buffer } from "buffer"
|
||||||
import { decryptData } from "../decipher"
|
import { decryptData } from "./decipher"
|
||||||
import type { IAdapter } from "../adapters"
|
import type { IAdapter } from "./adapter"
|
||||||
import { createEventEmitter } from "../ee"
|
import { createEventEmitter } from "./ee"
|
||||||
import { HMACAssertionError } from "../errors"
|
import { HMACAssertionError } from "./errors"
|
||||||
import type { i18n } from "../i18n"
|
import type { i18n } from "./i18n"
|
||||||
import type { ItemDetails, Overview, Profile } from "../types"
|
import type { ItemDetails, Overview, Profile } from "./types"
|
||||||
import { setIfAbsent } from "../util"
|
import { setIfAbsent } from "./util"
|
||||||
import type { EncryptedItem } from "./item"
|
import type { EncryptedItem } from "./models/item"
|
||||||
|
|
||||||
/** Encryption and MAC */
|
/** Encryption and MAC */
|
||||||
export interface Cipher {
|
export interface Cipher {
|
@ -1,6 +1,6 @@
|
|||||||
import { resolve, extname, basename } from "path"
|
import { resolve, extname, basename } from "path"
|
||||||
import invariant from "tiny-invariant"
|
import invariant from "tiny-invariant"
|
||||||
import type { IFileSystem } from "./adapters"
|
import type { IFileSystem } from "./adapter"
|
||||||
import { once } from "./util"
|
import { once } from "./util"
|
||||||
|
|
||||||
export type OnePasswordFileManager = ReturnType<typeof OnePasswordFileManager>
|
export type OnePasswordFileManager = ReturnType<typeof OnePasswordFileManager>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { resolve } from "path"
|
import { resolve } from "path"
|
||||||
import { Vault } from "./models/vault"
|
import { Vault } from "./models/vault"
|
||||||
import type { IAdapter } from "./adapters"
|
import type { IAdapter } from "./adapter"
|
||||||
import { asyncMap } from "./util"
|
import { asyncMap } from "./util"
|
||||||
|
|
||||||
export type { Vault } from "./models/vault"
|
export type { Vault } from "./models/vault"
|
||||||
@ -18,7 +18,7 @@ interface IOptions {
|
|||||||
/**
|
/**
|
||||||
* Adapter used to interact with the file system and cryptography modules
|
* Adapter used to interact with the file system and cryptography modules
|
||||||
*/
|
*/
|
||||||
adapter?: IAdapter
|
adapter?: IAdapter | Promise<IAdapter>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,11 +26,11 @@ interface IOptions {
|
|||||||
*/
|
*/
|
||||||
export class OnePassword {
|
export class OnePassword {
|
||||||
readonly #path: string
|
readonly #path: string
|
||||||
readonly #adapter: IAdapter
|
readonly #adapter: IAdapter | Promise<IAdapter>
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
path,
|
path,
|
||||||
adapter = process.browser ? null : require("./adapters/node").nodeAdapter,
|
adapter = process.browser ? null! : require("./adapter").nodeAdapter,
|
||||||
}: IOptions) {
|
}: IOptions) {
|
||||||
this.#adapter = adapter
|
this.#adapter = adapter
|
||||||
this.#path = path
|
this.#path = path
|
||||||
@ -40,11 +40,11 @@ export class OnePassword {
|
|||||||
* @returns A list of names of profiles of the current vault.
|
* @returns A list of names of profiles of the current vault.
|
||||||
*/
|
*/
|
||||||
async getProfileNames() {
|
async getProfileNames() {
|
||||||
const [fs, path] = [this.#adapter.fs, this.#path]
|
const { fs } = await this.#adapter
|
||||||
const children = await fs.readdir(path)
|
const children = await fs.readdir(this.#path)
|
||||||
const profiles: string[] = []
|
const profiles: string[] = []
|
||||||
await asyncMap(children, async child => {
|
await asyncMap(children, async child => {
|
||||||
const fullPath = resolve(path, child)
|
const fullPath = resolve(this.#path, child)
|
||||||
if (
|
if (
|
||||||
(await fs.isDirectory(fullPath)) &&
|
(await fs.isDirectory(fullPath)) &&
|
||||||
(await fs.exists(resolve(fullPath, "profile.js")))
|
(await fs.exists(resolve(fullPath, "profile.js")))
|
||||||
@ -59,6 +59,6 @@ export class OnePassword {
|
|||||||
* @returns A OnePassword Vault instance.
|
* @returns A OnePassword Vault instance.
|
||||||
*/
|
*/
|
||||||
async getProfile(profileName: string) {
|
async getProfile(profileName: string) {
|
||||||
return await Vault.of(this.#path, profileName, this.#adapter)
|
return await Vault.of(this.#path, profileName, await this.#adapter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Buffer } from "buffer"
|
import { Buffer } from "buffer"
|
||||||
import type { Crypto } from "./crypto"
|
import type { Crypto } from "../crypto"
|
||||||
import { invariant } from "../errors"
|
import { invariant } from "../errors"
|
||||||
|
|
||||||
type integer = number
|
type integer = number
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import type { ItemDetails, Overview } from "../types"
|
import type { ItemDetails, Overview } from "../types"
|
||||||
import type { Crypto } from "./crypto"
|
import type { Crypto } from "../crypto"
|
||||||
import { Attachment } from "./attachment"
|
import { Attachment } from "./attachment"
|
||||||
import { NotUnlockedError } from "../errors"
|
import { NotUnlockedError } from "../errors"
|
||||||
import type { Category } from "../models"
|
import type { Category } from "../models"
|
||||||
@ -8,7 +8,7 @@ export interface EncryptedItem {
|
|||||||
category: string // "001"
|
category: string // "001"
|
||||||
/** Unix seconds */
|
/** Unix seconds */
|
||||||
created: integer
|
created: integer
|
||||||
d: string // "b3BkYXRhMbt"
|
d: string // details, bass64
|
||||||
folder: string // 32 chars
|
folder: string // 32 chars
|
||||||
hmac: string // base64
|
hmac: string // base64
|
||||||
k: string // base64
|
k: string // base64
|
||||||
@ -16,6 +16,7 @@ export interface EncryptedItem {
|
|||||||
tx: integer // Unix seconds
|
tx: integer // Unix seconds
|
||||||
updated: integer // Unix seconds
|
updated: integer // Unix seconds
|
||||||
uuid: string // 32 chars
|
uuid: string // 32 chars
|
||||||
|
fave: number
|
||||||
trashed?: boolean
|
trashed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,6 +59,9 @@ export class Item {
|
|||||||
}
|
}
|
||||||
return this.#details!
|
return this.#details!
|
||||||
}
|
}
|
||||||
|
get fave() {
|
||||||
|
return this.#data.fave
|
||||||
|
}
|
||||||
|
|
||||||
constructor(crypto: Crypto, data: EncryptedItem) {
|
constructor(crypto: Crypto, data: EncryptedItem) {
|
||||||
this.#crypto = crypto
|
this.#crypto = crypto
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import type { IAdapter } from "../adapters"
|
import type { IAdapter } from "../adapter"
|
||||||
import { HMACAssertionError, invariant } from "../errors"
|
import { HMACAssertionError, invariant } from "../errors"
|
||||||
import { OnePasswordFileManager } from "../fs"
|
import { OnePasswordFileManager } from "../fs"
|
||||||
import { i18n } from "../i18n"
|
import { i18n } from "../i18n"
|
||||||
import type { EncryptedItem } from "./item"
|
import type { EncryptedItem } from "./item"
|
||||||
import { Crypto } from "./crypto"
|
import { Crypto } from "../crypto"
|
||||||
import { Item } from "./item"
|
import { Item } from "./item"
|
||||||
import type { Profile } from "../types"
|
import type { Profile } from "../types"
|
||||||
import { WeakValueMap } from "../weakMap"
|
import { WeakValueMap } from "../weakMap"
|
||||||
|
@ -23,21 +23,39 @@ export type TextField = {
|
|||||||
value: string
|
value: string
|
||||||
designation: string
|
designation: string
|
||||||
name: string
|
name: string
|
||||||
|
id?: undefined
|
||||||
}
|
}
|
||||||
export type BooleanField = {
|
export type BooleanField = {
|
||||||
type: FieldType.Checkbox
|
type: FieldType.Checkbox
|
||||||
name: string
|
name: string
|
||||||
value?: "✓" | string
|
value?: "✓" | string
|
||||||
|
designation?: undefined
|
||||||
|
id?: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ItemField =
|
export type ItemField =
|
||||||
| TextField
|
| TextField
|
||||||
| BooleanField
|
| 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.
|
// @TODO: This currently catches all item fields.
|
||||||
type: FieldType
|
type: FieldType
|
||||||
value: string
|
value: string
|
||||||
designation?: string
|
designation?: string
|
||||||
|
id?: undefined
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,6 +148,9 @@ export interface ItemDetails {
|
|||||||
}[]
|
}[]
|
||||||
/** Web form fields */
|
/** Web form fields */
|
||||||
fields?: ItemField[]
|
fields?: ItemField[]
|
||||||
|
/** Plain password items */
|
||||||
|
backupKeys?: string[]
|
||||||
|
password?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Folder {
|
export interface Folder {
|
||||||
|
3
packages/web/.gitignore
vendored
3
packages/web/.gitignore
vendored
@ -1,6 +1,7 @@
|
|||||||
|
src/third-party-licenses.json
|
||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
bundle
|
bundle
|
||||||
*.local
|
*.local
|
||||||
*.yml.d.ts
|
*.yml.d.ts
|
29
packages/web/electron-builder.yml
Normal file
29
packages/web/electron-builder.yml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# 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({
|
build({
|
||||||
bundle: true,
|
bundle: true,
|
||||||
define: {},
|
define: {},
|
||||||
entryPoints: ["./src/electron/index.ts"],
|
entryPoints: ["./src/electron/index.ts", "./src/electron/preload.ts"],
|
||||||
outdir: "./dist/main",
|
outdir: "./dist/main",
|
||||||
external: builtinModules.concat("electron"),
|
external: builtinModules.concat("electron"),
|
||||||
target: ["chrome90"],
|
target: ["chrome96"],
|
||||||
tsconfig: "./tsconfig.json",
|
tsconfig: "./tsconfig.json",
|
||||||
sourcemap: "external",
|
sourcemap: "external",
|
||||||
minify: process.env.NODE_ENV === "production",
|
minify: process.env.NODE_ENV === "production",
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
<title>OPVault Viewer</title>
|
<title>OPVault Viewer</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -6,53 +6,40 @@
|
|||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"description": "OnePassword local vault viewer",
|
"description": "OnePassword local vault viewer",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "concurrently vite npm:start",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"start": "./esbuild.js && NODE_ENV=development electron --enable-transparent-visuals --disable-gpu ./dist/main/index.js",
|
"start": "./esbuild.js && NODE_ENV=development electron --enable-transparent-visuals --disable-gpu ./dist/main/index.js",
|
||||||
"bundle": "./scripts/build.sh"
|
"bundle": "./scripts/build.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"dependencies": {
|
||||||
"@emotion/css": "^11.5.0",
|
"@emotion/css": "^11.7.1",
|
||||||
"@emotion/react": "^11.5.0",
|
"@emotion/react": "^11.7.1",
|
||||||
"@emotion/styled": "^11.3.0",
|
"@emotion/styled": "^11.6.0",
|
||||||
"@rollup/plugin-yaml": "^3.1.0",
|
|
||||||
"@types/react": "^17.0.0",
|
|
||||||
"@types/react-dom": "^17.0.0",
|
|
||||||
"@vitejs/plugin-react": "^1.0.0",
|
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"electron": "^15.2.0",
|
|
||||||
"electron-builder": "^22.13.1",
|
|
||||||
"esbuild": "^0.13.6",
|
|
||||||
"js-yaml": "^4.1.0",
|
|
||||||
"opvault.js": "*",
|
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"react": "^17.0.0",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.0",
|
"react-dom": "^17.0.2",
|
||||||
"react-icons": "^4.3.1",
|
"react-icons": "^4.3.1",
|
||||||
"sass": "^1.43.4",
|
"react-idle-timer": "4.6.4"
|
||||||
"typescript": "^4.3.2",
|
|
||||||
"vite": "^2.6.4"
|
|
||||||
},
|
},
|
||||||
"build": {
|
"devDependencies": {
|
||||||
"appId": "com.proteria.opvault",
|
"@babel/core": "^7.16.7",
|
||||||
"productName": "OPVault Viewer",
|
"@emotion/babel-plugin": "^11.7.2",
|
||||||
"files": [
|
"@rollup/plugin-yaml": "^3.1.0",
|
||||||
"**/*"
|
"@types/react": "^17.0.37",
|
||||||
],
|
"@types/react-dom": "^17.0.11",
|
||||||
"icon": "dist/512x512.png",
|
"@vitejs/plugin-react": "^1.1.3",
|
||||||
"directories": {
|
"@types/babel__core": "^7.1.18",
|
||||||
"output": "bundle",
|
"concurrently": "^6.5.1",
|
||||||
"app": "dist",
|
"electron": "^16.0.5",
|
||||||
"buildResources": "build"
|
"electron-builder": "^22.14.5",
|
||||||
},
|
"esbuild": "^0.14.5",
|
||||||
"linux": {
|
"js-yaml": "^4.1.0",
|
||||||
"executableName": "opvault",
|
"lodash": "^4.17.21",
|
||||||
"category": "Utility",
|
"opvault.js": "*",
|
||||||
"icon": "512x512.png",
|
"sass": "^1.45.0",
|
||||||
"target": [
|
"typescript": "^4.5.4",
|
||||||
"AppImage"
|
"vite": "^2.7.3"
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,8 +12,13 @@ fs.writeFileSync(
|
|||||||
dtsPath,
|
dtsPath,
|
||||||
`type Translation = Record<string, string>;
|
`type Translation = Record<string, string>;
|
||||||
declare const exportee: {
|
declare const exportee: {
|
||||||
${Object.keys(json)
|
${Object.entries(json)
|
||||||
.map(x => `${x}: Translation;`)
|
.map(
|
||||||
|
([category, value]) =>
|
||||||
|
`${category}: {\n${Object.keys(value)
|
||||||
|
.map(key => ` ${key}: Translation;`)
|
||||||
|
.join("\n")}\n };`
|
||||||
|
)
|
||||||
.join("\n ")}
|
.join("\n ")}
|
||||||
};
|
};
|
||||||
export default exportee;
|
export default exportee;
|
||||||
|
42
packages/web/scripts/build-third-party-license-info.js
Executable file
42
packages/web/scripts/build-third-party-license-info.js
Executable file
@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require("fs")
|
||||||
|
const { resolve } = require("path")
|
||||||
|
|
||||||
|
const root = resolve(__dirname, "../../..")
|
||||||
|
const packages = [
|
||||||
|
root,
|
||||||
|
resolve(root, "packages/web"),
|
||||||
|
resolve(root, "packages/opvault.js"),
|
||||||
|
]
|
||||||
|
|
||||||
|
const readJSON = path => JSON.parse(fs.readFileSync(path, "utf-8"))
|
||||||
|
const infoMap = Object.fromEntries(
|
||||||
|
packages.flatMap(dir => {
|
||||||
|
const rootPkg = readJSON(resolve(dir, "package.json"))
|
||||||
|
const dependencies = Object.keys(rootPkg.dependencies || {})
|
||||||
|
return dependencies.map(dependency => {
|
||||||
|
const pkgDir = resolve(dir, "node_modules", dependency)
|
||||||
|
const pkg = readJSON(resolve(pkgDir, "package.json"))
|
||||||
|
const licenseFile = fs
|
||||||
|
.readdirSync(pkgDir)
|
||||||
|
.filter(x => x.toLowerCase().startsWith("license"))
|
||||||
|
if (licenseFile.length !== 1) {
|
||||||
|
console.error(fs.readdirSync(pkgDir))
|
||||||
|
throw new Error(`Cannot determine license file for ${pkg.name}`)
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
pkg.name,
|
||||||
|
{
|
||||||
|
name: pkg.name,
|
||||||
|
author: pkg.author?.name ?? pkg.author,
|
||||||
|
license: fs.readFileSync(resolve(pkgDir, licenseFile[0]), "utf-8"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
resolve(__dirname, "../src/third-party-licenses.json"),
|
||||||
|
JSON.stringify(infoMap, null, 2)
|
||||||
|
)
|
@ -1,5 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
./scripts/build-i18n-yml-typedef.js
|
||||||
|
./scripts/build-third-party-license.js
|
||||||
|
./scripts/build-package-json.js
|
||||||
npx vite build
|
npx vite build
|
||||||
NODE_ENV=production ./esbuild.js
|
NODE_ENV=production ./esbuild.js
|
||||||
./scripts/build-package-json.js
|
|
||||||
./node_modules/.bin/electron-builder build
|
./node_modules/.bin/electron-builder build
|
41
packages/web/scripts/vite-babel.ts
Normal file
41
packages/web/scripts/vite-babel.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import * as babel from "@babel/core"
|
||||||
|
import type { PluginOption, TransformResult } from "vite"
|
||||||
|
|
||||||
|
const sourceRegex = /\.(j|t)sx?$/
|
||||||
|
|
||||||
|
export default function macrosPlugin(): PluginOption {
|
||||||
|
return {
|
||||||
|
name: "babel-macros",
|
||||||
|
enforce: "pre",
|
||||||
|
transform(source: string, filename: string) {
|
||||||
|
if (filename.includes("node_modules")) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (!sourceRegex.test(filename)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hasBabelMacro = source.includes('.macro"')
|
||||||
|
const hasEmotion = source.includes("@emotion")
|
||||||
|
if (!hasBabelMacro && !hasEmotion) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const result = babel.transformSync(source, {
|
||||||
|
filename,
|
||||||
|
parserOpts: {
|
||||||
|
plugins: ["jsx", "typescript", "decorators-legacy"],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
hasBabelMacro && require.resolve("babel-plugin-macros"),
|
||||||
|
hasEmotion && require.resolve("@emotion/babel-plugin"),
|
||||||
|
].filter(Boolean),
|
||||||
|
generatorOpts: {
|
||||||
|
decoratorsBeforeExport: true,
|
||||||
|
},
|
||||||
|
babelrc: false,
|
||||||
|
configFile: false,
|
||||||
|
sourceMaps: true,
|
||||||
|
})
|
||||||
|
return result as TransformResult | null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
@ -1,45 +1,52 @@
|
|||||||
import { useCallback, useState } from "react"
|
/* eslint-disable import/no-unresolved */
|
||||||
import type { Vault } from "opvault.js"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { OnePassword } from "opvault.js"
|
import type { Vault, OnePassword } from "opvault.js"
|
||||||
import { getBrowserAdapter } from "opvault.js/src/adapters/browser"
|
import { useIdleTimer } from "react-idle-timer/modern"
|
||||||
import { VaultView } from "./pages/Vault"
|
import { VaultView } from "./pages/Vault"
|
||||||
import { PickOPVault } from "./pages/PickOPVault"
|
import { VaultPicker } from "./pages/VaultPicker"
|
||||||
import { Unlock } from "./pages/Unlock"
|
import { Key, useStorage } from "./utils/localStorage"
|
||||||
|
|
||||||
export const App: React.FC = () => {
|
export const App: React.FC = () => {
|
||||||
const [instance, setInstance] = useState<OnePassword>()
|
const [instance, setInstance] = useState<OnePassword>()
|
||||||
const [vault, setVault] = useState<Vault>()
|
const [vault, setVault] = useState<Vault>()
|
||||||
|
|
||||||
const unlock = useCallback(
|
const [enableAutoLock] = useStorage(Key.ENABLE_AUTO_LOCK)
|
||||||
async (profile: string, password: string) => {
|
const [autolockAfter] = useStorage(Key.AUTO_LOCK_AFTER)
|
||||||
const vault = await instance!.getProfile(profile!)
|
|
||||||
await vault.unlock(password)
|
|
||||||
setVault(vault)
|
|
||||||
},
|
|
||||||
[instance]
|
|
||||||
)
|
|
||||||
|
|
||||||
const setHandle = useCallback(async (handle: FileSystemDirectoryHandle) => {
|
|
||||||
const adapter = getBrowserAdapter(handle)
|
|
||||||
const instance = new OnePassword({ path: "/", adapter })
|
|
||||||
setInstance(instance)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onLock = useCallback(() => {
|
const onLock = useCallback(() => {
|
||||||
vault?.lock()
|
vault?.lock()
|
||||||
setVault(undefined)
|
setVault(undefined)
|
||||||
}, [vault])
|
}, [vault])
|
||||||
|
|
||||||
if (!instance) {
|
const onAutoLock = useCallback(() => {
|
||||||
return <PickOPVault setHandle={setHandle} />
|
if (enableAutoLock) {
|
||||||
}
|
onLock()
|
||||||
|
}
|
||||||
|
}, [onLock, enableAutoLock])
|
||||||
|
|
||||||
|
const { reset, pause } = useIdleTimer({
|
||||||
|
timeout: autolockAfter * 1000,
|
||||||
|
onIdle: onAutoLock,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (vault) {
|
||||||
|
reset()
|
||||||
|
} else {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}, [vault])
|
||||||
|
|
||||||
if (!vault) {
|
if (!vault) {
|
||||||
return <Unlock instance={instance} onUnlock={unlock} />
|
return (
|
||||||
|
<VaultPicker
|
||||||
|
instance={instance}
|
||||||
|
setInstance={setInstance}
|
||||||
|
vault={vault}
|
||||||
|
setVault={setVault}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <VaultView onLock={onLock} vault={vault} />
|
||||||
<div>
|
|
||||||
<VaultView onLock={onLock} vault={vault} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
14
packages/web/src/SideEffect.ts
Normal file
14
packages/web/src/SideEffect.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
})
|
65
packages/web/src/about/LicenseViewer.tsx
Normal file
65
packages/web/src/about/LicenseViewer.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { ClickableContainer } from "../components/ItemFieldValue"
|
||||||
|
import { scrollbar } from "../styles"
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
`
|
||||||
|
const ListContainer = styled.div`
|
||||||
|
min-width: 150px;
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
margin-block-start: 0;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
line-height: 1.6em;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const LicenseText = styled.div`
|
||||||
|
flex-grow: 1;
|
||||||
|
font-family: var(--monospace);
|
||||||
|
max-height: 575px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const LicenseView = () => {
|
||||||
|
const [licenseInfo, setLicenseInfo] = useState<
|
||||||
|
typeof import("../third-party-licenses.json")
|
||||||
|
>(() => ({} as any))
|
||||||
|
const names = useMemo(() => Object.keys(licenseInfo), [licenseInfo])
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<string>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
import("../third-party-licenses.json").then(json => setLicenseInfo(json.default))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(names[0])
|
||||||
|
}, [names])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<ListContainer>
|
||||||
|
<ul>
|
||||||
|
{names.map(name => (
|
||||||
|
<li
|
||||||
|
key={name}
|
||||||
|
style={name === selected ? { fontWeight: 600 } : undefined}
|
||||||
|
onClick={() => setSelected(name)}
|
||||||
|
>
|
||||||
|
<ClickableContainer>{name}</ClickableContainer>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ListContainer>
|
||||||
|
|
||||||
|
<LicenseText className={scrollbar}>
|
||||||
|
{licenseInfo[selected as any]?.license}
|
||||||
|
</LicenseText>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
27
packages/web/src/about/index.tsx
Normal file
27
packages/web/src/about/index.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { Modal } from "../components/Modal"
|
||||||
|
import { useTranslate } from "../i18n"
|
||||||
|
import { LicenseView } from "./LicenseViewer"
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
width: 800px;
|
||||||
|
min-height: 450px;
|
||||||
|
`
|
||||||
|
const LicenseSectionHeader = styled.h3`
|
||||||
|
margin-top: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const About: React.FC<{
|
||||||
|
show: boolean
|
||||||
|
onHide(): void
|
||||||
|
}> = ({ show, onHide }) => {
|
||||||
|
const t = useTranslate()
|
||||||
|
return (
|
||||||
|
<Modal maxWidth={800} show={show} title={t.label.about_app} onClose={onHide}>
|
||||||
|
<Container>
|
||||||
|
<LicenseSectionHeader>Licenses</LicenseSectionHeader>
|
||||||
|
<LicenseView />
|
||||||
|
</Container>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
@ -11,10 +11,12 @@ const Container = styled.div`
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
font-size: 8em;
|
font-size: 6em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px 25px;
|
padding: 20px 25px;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
min-width: 75vw;
|
||||||
|
z-index: 2;
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from "react"
|
||||||
import { Category } from "opvault.js"
|
import { Category } from "opvault.js"
|
||||||
import { cx, css } from "@emotion/css"
|
import { cx, css } from "@emotion/css"
|
||||||
import { BsBank2, BsPeopleFill } from "react-icons/bs"
|
import { BsBank2, BsPeopleFill } from "react-icons/bs"
|
||||||
@ -77,14 +78,11 @@ interface CategoryIconProps {
|
|||||||
category: Category
|
category: Category
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CategoryIcon: React.FC<CategoryIconProps> = ({
|
export const CategoryIcon = memo<CategoryIconProps>(
|
||||||
className,
|
({ className, category, style, fill }) => {
|
||||||
category,
|
const Component = getComponent(category)
|
||||||
style,
|
return Component ? (
|
||||||
fill,
|
<Component className={cx(reactIconClass, className)} fill={fill} style={style} />
|
||||||
}) => {
|
) : null
|
||||||
const Component = getComponent(category)
|
}
|
||||||
return Component ? (
|
)
|
||||||
<Component className={cx(reactIconClass, className)} fill={fill} style={style} />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
|
198
packages/web/src/components/FilteredVaultView.tsx
Normal file
198
packages/web/src/components/FilteredVaultView.tsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import type { Item } from "opvault.js"
|
||||||
|
import { Category } from "opvault.js"
|
||||||
|
import { IoSearch } from "react-icons/io5"
|
||||||
|
import { ItemList } from "../components/ItemList"
|
||||||
|
import { ItemView } from "../components/Item"
|
||||||
|
import { reactIconClass } from "../components/CategoryIcon"
|
||||||
|
import { useTranslate } from "../i18n/index"
|
||||||
|
import { scrollbar } from "../styles"
|
||||||
|
|
||||||
|
const ListContainer = styled.div`
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
width: 350px;
|
||||||
|
margin-right: 10px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-y: overlay;
|
||||||
|
overflow-x: hidden;
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background: #202020;
|
||||||
|
border-right-color: transparent;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const ItemContainer = styled.div`
|
||||||
|
width: calc(100% - 300px);
|
||||||
|
overflow: hidden;
|
||||||
|
`
|
||||||
|
const SearchContainer = styled.div`
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
|
margin: 10px 0;
|
||||||
|
margin-right: 10px;
|
||||||
|
`
|
||||||
|
const SortContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
margin: 10px 0;
|
||||||
|
`
|
||||||
|
const CategorySelect = styled.select`
|
||||||
|
width: 50%;
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-right: 5px;
|
||||||
|
`
|
||||||
|
const SortSelect = styled.select`
|
||||||
|
width: calc(50% - 25px);
|
||||||
|
`
|
||||||
|
|
||||||
|
const SearchInput = styled.input`
|
||||||
|
--margin: 10px;
|
||||||
|
width: calc(100% - var(--margin) * 2 + 9px);
|
||||||
|
margin: 0 var(--margin);
|
||||||
|
padding-left: 2em !important;
|
||||||
|
`
|
||||||
|
const SearchIcon = styled(IoSearch)`
|
||||||
|
position: absolute;
|
||||||
|
top: 9px;
|
||||||
|
left: 20px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const enum SortBy {
|
||||||
|
Name,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilteredVaultView: React.FC<{ items: Item[] }> = ({ items }) => {
|
||||||
|
const t = useTranslate()
|
||||||
|
const [item, setItem] = useState<Item>()
|
||||||
|
const [category, setCategory] = useState<Category>()
|
||||||
|
const [sortBy, setSortBy] = useState(SortBy.Name)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItem(undefined)
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const compareFn = useMemo((): ((a: Item, b: Item) => number) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case SortBy.Name:
|
||||||
|
return (a, b) => (a.overview.title ?? "").localeCompare(b?.overview.title ?? "")
|
||||||
|
case SortBy.CreatedAt:
|
||||||
|
return (a, b) => b.createdAt - a.createdAt
|
||||||
|
case SortBy.UpdatedAt:
|
||||||
|
return (a, b) => b.updatedAt - a.updatedAt
|
||||||
|
}
|
||||||
|
}, [sortBy])
|
||||||
|
|
||||||
|
const sortedItem = useMemo(() => items.slice().sort(compareFn), [items, compareFn])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let items = sortedItem.filter(x => x.category !== Category.Tombstone)
|
||||||
|
if (category != null) {
|
||||||
|
items = items.filter(x => x.category === category)
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: Item[] = items
|
||||||
|
if (search) {
|
||||||
|
res = []
|
||||||
|
for (const x of items) {
|
||||||
|
const compare = Math.max(
|
||||||
|
stringCompare(search, x.overview.title),
|
||||||
|
stringCompare(search, x.overview.ainfo)
|
||||||
|
) as CompareResult
|
||||||
|
switch (compare) {
|
||||||
|
case CompareResult.NoMatch:
|
||||||
|
continue
|
||||||
|
case CompareResult.Includes:
|
||||||
|
res.push(x)
|
||||||
|
break
|
||||||
|
case CompareResult.Equals:
|
||||||
|
res.unshift(x)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}, [sortedItem, search, category])
|
||||||
|
|
||||||
|
const categoryMap = useMemo(
|
||||||
|
(): [Category | undefined, string][] => [
|
||||||
|
[undefined, t.label.category_all],
|
||||||
|
[Category.Login, t.label.category_login],
|
||||||
|
[Category.SecureNote, t.label.category_secure_note],
|
||||||
|
[Category.CreditCard, t.label.category_credit_card],
|
||||||
|
[Category.Identity, t.label.category_identity],
|
||||||
|
[Category.Password, t.label.category_password],
|
||||||
|
[Category.Membership, t.label.category_membership],
|
||||||
|
[Category.Database, t.label.category_database],
|
||||||
|
[Category.BankAccount, t.label.category_bank_account],
|
||||||
|
[Category.Email, t.label.category_email],
|
||||||
|
[Category.SoftwareLicense, t.label.category_software_license],
|
||||||
|
[Category.SSN, t.label.category_ssn],
|
||||||
|
[Category.Passport, t.label.category_passport],
|
||||||
|
[Category.OutdoorLicense, t.label.category_outdoor_license],
|
||||||
|
[Category.DriverLicense, t.label.category_driver_license],
|
||||||
|
[Category.Rewards, t.label.category_rewards],
|
||||||
|
[Category.Router, t.label.category_router],
|
||||||
|
[Category.Server, t.label.category_server],
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListContainer className={scrollbar}>
|
||||||
|
<SearchContainer>
|
||||||
|
<SearchInput
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<SearchIcon className={reactIconClass} />
|
||||||
|
</SearchContainer>
|
||||||
|
|
||||||
|
<SortContainer>
|
||||||
|
<CategorySelect
|
||||||
|
value={category}
|
||||||
|
onChange={e => setCategory((e.currentTarget.value as Category) || undefined)}
|
||||||
|
>
|
||||||
|
{categoryMap.map(([value, name]) => (
|
||||||
|
<option value={value || ""} key={value}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</CategorySelect>
|
||||||
|
|
||||||
|
<SortSelect value={sortBy} onChange={e => setSortBy(+e.currentTarget.value)}>
|
||||||
|
<option value={SortBy.Name}>{t.options.sort_by_name}</option>
|
||||||
|
<option value={SortBy.CreatedAt}>{t.options.sort_by_created_at}</option>
|
||||||
|
<option value={SortBy.UpdatedAt}>{t.options.sort_by_updated_at}</option>
|
||||||
|
</SortSelect>
|
||||||
|
</SortContainer>
|
||||||
|
<ItemList items={filtered} onSelect={setItem} selected={item} />
|
||||||
|
</ListContainer>
|
||||||
|
<ItemContainer>
|
||||||
|
{item && <ItemView className={scrollbar} item={item} />}
|
||||||
|
</ItemContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CompareResult {
|
||||||
|
NoMatch,
|
||||||
|
Includes,
|
||||||
|
Equals,
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringCompare(search: string, source?: string) {
|
||||||
|
if (!search) return CompareResult.Includes
|
||||||
|
if (!source) return CompareResult.NoMatch
|
||||||
|
source = source.toLocaleLowerCase()
|
||||||
|
search = search.toLocaleUpperCase()
|
||||||
|
const includes = source.includes(search.toLocaleLowerCase())
|
||||||
|
if (includes) {
|
||||||
|
return source.length === search.length ? CompareResult.Equals : CompareResult.Includes
|
||||||
|
}
|
||||||
|
return CompareResult.NoMatch
|
||||||
|
}
|
@ -1,6 +1,9 @@
|
|||||||
import styled from "@emotion/styled"
|
import styled from "@emotion/styled"
|
||||||
import type { Attachment, AttachmentMetadata, Item } from "opvault.js"
|
import type { Attachment, AttachmentMetadata, Item, ItemField } from "opvault.js"
|
||||||
import { useEffect, useState } from "react"
|
import type { ItemDetails } from "opvault.js/src/types"
|
||||||
|
import { memo, useEffect, useState } from "react"
|
||||||
|
import { useTranslate } from "../i18n"
|
||||||
|
import { ItemNoTitle } from "../styles"
|
||||||
import { CategoryIcon } from "./CategoryIcon"
|
import { CategoryIcon } from "./CategoryIcon"
|
||||||
import { ItemDates } from "./ItemDates"
|
import { ItemDates } from "./ItemDates"
|
||||||
import {
|
import {
|
||||||
@ -9,10 +12,12 @@ import {
|
|||||||
FieldTitle,
|
FieldTitle,
|
||||||
ItemDetailsFieldView,
|
ItemDetailsFieldView,
|
||||||
} from "./ItemField"
|
} from "./ItemField"
|
||||||
|
import { PasswordFieldView } from "./ItemFieldValue"
|
||||||
import { ItemWarning } from "./ItemWarning"
|
import { ItemWarning } from "./ItemWarning"
|
||||||
|
|
||||||
interface ItemViewProps {
|
interface ItemViewProps {
|
||||||
item: Item
|
item: Item
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header = styled.div`
|
const Header = styled.div`
|
||||||
@ -54,81 +59,117 @@ const AttachmentContainer = styled.div`
|
|||||||
display: flex;
|
display: flex;
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
`
|
`
|
||||||
|
const PlainNotes = styled.p`
|
||||||
|
white-space: pre-wrap;
|
||||||
|
`
|
||||||
|
|
||||||
export const ItemView: React.FC<ItemViewProps> = ({ item }) => (
|
const SectionsView = memo<{ sections?: ItemDetails["sections"] }>(({ sections }) =>
|
||||||
<Container>
|
sections?.length ? (
|
||||||
<Inner>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<ItemWarning item={item} />
|
{sections
|
||||||
<Header>
|
.filter(s => s.fields?.some(x => x.v != null))
|
||||||
{item.details.fields == null}
|
.map((section, i) => (
|
||||||
<Icon category={item.category} />
|
<div key={i}>
|
||||||
<ItemTitle>{item.overview.title}</ItemTitle>
|
{section.title != null && <SectionTitle>{section.title}</SectionTitle>}
|
||||||
</Header>
|
{section.fields?.map((field, j) => (
|
||||||
<details>
|
<ItemFieldView key={j} field={field} />
|
||||||
<summary>JSON</summary>
|
))}
|
||||||
<pre>
|
</div>
|
||||||
{JSON.stringify({ overview: item.overview, details: item.details }, null, 2)}
|
))}
|
||||||
</pre>
|
</div>
|
||||||
</details>
|
) : null
|
||||||
|
)
|
||||||
|
|
||||||
<div style={{ marginBottom: 20 }}>
|
const FieldsView = memo<{ fields?: ItemField[] }>(({ fields }) =>
|
||||||
{item.details.sections
|
fields?.length ? (
|
||||||
?.filter(s => s.fields?.some(x => x.v != null))
|
<div style={{ marginBottom: 20 }}>
|
||||||
.map((section, i) => (
|
{fields.map((field, i) => (
|
||||||
<div key={i}>
|
<ItemDetailsFieldView key={i} field={field} />
|
||||||
{section.title != null && <SectionTitle>{section.title}</SectionTitle>}
|
))}
|
||||||
{section.fields?.map((field, j) => (
|
</div>
|
||||||
<ItemFieldView key={j} field={field} />
|
) : 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>
|
</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>
|
<ExtraField>
|
||||||
<FieldTitle>notes</FieldTitle>
|
<ItemDates item={item} />
|
||||||
<div>
|
|
||||||
<p>{item.details.notesPlain}</p>
|
|
||||||
</div>
|
|
||||||
</ExtraField>
|
</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 }) {
|
function AttachmentView({ file }: { file: Attachment }) {
|
||||||
const [metadata, setMetadata] = useState<AttachmentMetadata>()
|
const [metadata, setMetadata] = useState<AttachmentMetadata>()
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from "react"
|
||||||
import styled from "@emotion/styled"
|
import styled from "@emotion/styled"
|
||||||
import type { Item } from "opvault.js"
|
import type { Item } from "opvault.js"
|
||||||
import { useTranslate } from "../i18n"
|
import { useTranslate } from "../i18n"
|
||||||
@ -7,18 +8,19 @@ const Container = styled.div`
|
|||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
line-height: 1.5em;
|
line-height: 1.5em;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
user-select: none;
|
||||||
`
|
`
|
||||||
|
|
||||||
export const ItemDates: React.FC<{ item: Item }> = ({ item }) => {
|
export const ItemDates = memo<{ item: Item }>(({ item }) => {
|
||||||
const t = useTranslate()
|
const t = useTranslate()
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<div>
|
<div>
|
||||||
{t.label_last_updated}: {new Date(item.updatedAt).toLocaleString()}
|
{t.label.last_updated}: {new Date(item.updatedAt).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{t.label_created_at}: {new Date(item.createdAt).toLocaleString()}
|
{t.label.created_at}: {new Date(item.createdAt).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from "react"
|
||||||
import styled from "@emotion/styled"
|
import styled from "@emotion/styled"
|
||||||
import type { ItemField, ItemSection } from "opvault.js"
|
import type { ItemField, ItemSection } from "opvault.js"
|
||||||
import { ErrorBoundary } from "./ErrorBoundary"
|
import { ErrorBoundary } from "./ErrorBoundary"
|
||||||
@ -11,11 +12,12 @@ const Container: React.FC = styled.div`
|
|||||||
export const FieldTitle: React.FC = styled.div`
|
export const FieldTitle: React.FC = styled.div`
|
||||||
font-size: 85%;
|
font-size: 85%;
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
|
user-select: none;
|
||||||
`
|
`
|
||||||
|
|
||||||
export const ItemFieldView: React.FC<{
|
export const ItemFieldView = memo<{
|
||||||
field: ItemSection.Any
|
field: ItemSection.Any
|
||||||
}> = ({ field }) => {
|
}>(({ field }) => {
|
||||||
if (field.v == null) {
|
if (field.v == null) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -28,12 +30,15 @@ export const ItemFieldView: React.FC<{
|
|||||||
</Container>
|
</Container>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
export const ItemDetailsFieldView: React.FC<{
|
const hideIds = new Set(["use_desktop", "use_mobile", "use_html"])
|
||||||
|
const hideNames = new Set(["remember"])
|
||||||
|
|
||||||
|
export const ItemDetailsFieldView = memo<{
|
||||||
field: ItemField
|
field: ItemField
|
||||||
}> = ({ field }) => {
|
}>(({ field }) => {
|
||||||
if (field.value == null) {
|
if (field.value == null || hideIds.has(field.id!) || hideNames.has(field.name)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,4 +50,4 @@ export const ItemDetailsFieldView: React.FC<{
|
|||||||
</Container>
|
</Container>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
@ -4,10 +4,11 @@ import styled from "@emotion/styled"
|
|||||||
const Container = styled.menu`
|
const Container = styled.menu`
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
box-shadow: #0004 0px 1px 4px;
|
box-shadow: rgb(15 15 15 / 5%) 0px 0px 0px 1px, rgb(15 15 15 / 10%) 0px 3px 6px,
|
||||||
|
rgb(15 15 15 / 20%) 0px 9px 24px;
|
||||||
left: 99%;
|
left: 99%;
|
||||||
margin-block-start: 0;
|
margin-block-start: 0;
|
||||||
min-width: 120px;
|
min-width: 195px;
|
||||||
padding-inline-start: 0;
|
padding-inline-start: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -33,16 +34,23 @@ const Separator = styled.div`
|
|||||||
|
|
||||||
const Item = styled.div`
|
const Item = styled.div`
|
||||||
cursor: default;
|
cursor: default;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 2.5em;
|
height: 2.3em;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-left: 1em;
|
padding-left: 1em;
|
||||||
|
padding-right: 5px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
&:first-of-type {
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
}
|
||||||
|
&:last-of-type {
|
||||||
|
border-radius: 0 0 3px 3px;
|
||||||
|
}
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #ddd;
|
background-color: #ddd;
|
||||||
border-radius: 3px;
|
|
||||||
.item-field-context-menu {
|
.item-field-context-menu {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
@ -2,24 +2,46 @@ import styled from "@emotion/styled"
|
|||||||
import type { ItemSection, ItemField } from "opvault.js"
|
import type { ItemSection, ItemField } from "opvault.js"
|
||||||
import { FieldType } from "opvault.js"
|
import { FieldType } from "opvault.js"
|
||||||
import { useCallback, useMemo, useState } from "react"
|
import { useCallback, useMemo, useState } from "react"
|
||||||
|
import { useTranslate } from "../i18n"
|
||||||
import { parseMonthYear } from "../utils"
|
import { parseMonthYear } from "../utils"
|
||||||
import { BigTextView } from "./BigTextView"
|
import { BigTextView } from "./BigTextView"
|
||||||
import { ErrorBoundary } from "./ErrorBoundary"
|
import { ErrorBoundary } from "./ErrorBoundary"
|
||||||
import { useItemFieldContextMenu } from "./ItemFieldContextMenu"
|
import { useItemFieldContextMenu } from "./ItemFieldContextMenu"
|
||||||
|
import { toast, ToastType } from "./Toast"
|
||||||
|
|
||||||
const Container = styled.div``
|
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 Password: React.FC<{
|
const Password: React.FC<{
|
||||||
field: ItemSection.Concealed
|
field: Pick<ItemSection.Concealed, "v">
|
||||||
}> = ({ field }) => {
|
}> = ({ field }) => {
|
||||||
|
const t = useTranslate()
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
const [bigText, showBigText] = useState(false)
|
const [bigText, showBigText] = useState(false)
|
||||||
|
|
||||||
const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu()
|
const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu()
|
||||||
const onToggle = useCallback(() => setShow(x => !x), [])
|
const onToggle = useCallback(() => setShow(x => !x), [])
|
||||||
const onCopy = useCallback(() => {
|
const onCopy = useCopy(field.v)
|
||||||
navigator.clipboard.writeText(field.v)
|
|
||||||
}, [field.v])
|
|
||||||
const onOpenBigText = useCallback(() => {
|
const onOpenBigText = useCallback(() => {
|
||||||
showBigText(true)
|
showBigText(true)
|
||||||
}, [])
|
}, [])
|
||||||
@ -32,6 +54,7 @@ const Password: React.FC<{
|
|||||||
<Container
|
<Container
|
||||||
onContextMenu={onRightClick}
|
onContextMenu={onRightClick}
|
||||||
onDoubleClick={() => setShow(x => !x)}
|
onDoubleClick={() => setShow(x => !x)}
|
||||||
|
onClick={onCopy}
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--monospace)",
|
fontFamily: "var(--monospace)",
|
||||||
...(!show && { userSelect: "none" }),
|
...(!show && { userSelect: "none" }),
|
||||||
@ -41,9 +64,11 @@ const Password: React.FC<{
|
|||||||
</Container>
|
</Container>
|
||||||
{bigText && <BigTextView onClose={onCloseBigText}>{field.v}</BigTextView>}
|
{bigText && <BigTextView onClose={onCloseBigText}>{field.v}</BigTextView>}
|
||||||
<ContextMenuContainer>
|
<ContextMenuContainer>
|
||||||
<Item onClick={onCopy}>Copier</Item>
|
<Item onClick={onCopy}>{t.action.copy}</Item>
|
||||||
<Item onClick={onToggle}>{show ? "Cacher" : "Afficher"}</Item>
|
<Item onClick={onToggle}>{show ? t.action.hide : t.action.show}</Item>
|
||||||
{!bigText && <Item onClick={onOpenBigText}>Afficher en gros caractères</Item>}
|
{!bigText && (
|
||||||
|
<Item onClick={onOpenBigText}>{t.action.show_in_big_characters}</Item>
|
||||||
|
)}
|
||||||
</ContextMenuContainer>
|
</ContextMenuContainer>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@ -65,13 +90,13 @@ const DateView: React.FC<{ field: ItemSection.Date }> = ({ field }) => {
|
|||||||
|
|
||||||
const TextView: React.FC<{ value: string }> = ({ value }) => {
|
const TextView: React.FC<{ value: string }> = ({ value }) => {
|
||||||
const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu()
|
const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu()
|
||||||
const onCopy = useCallback(() => {
|
const onCopy = useCopy(value)
|
||||||
navigator.clipboard.writeText(value)
|
|
||||||
}, [value])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Container onContextMenu={onRightClick}>{value}</Container>
|
<Container onContextMenu={onRightClick} onClick={onCopy}>
|
||||||
|
{value}
|
||||||
|
</Container>
|
||||||
<ContextMenuContainer>
|
<ContextMenuContainer>
|
||||||
<Item onClick={onCopy}>Copier</Item>
|
<Item onClick={onCopy}>Copier</Item>
|
||||||
</ContextMenuContainer>
|
</ContextMenuContainer>
|
||||||
@ -115,16 +140,13 @@ export const ItemFieldValue: React.FC<{
|
|||||||
export const ItemDetailsFieldValue: React.FC<{
|
export const ItemDetailsFieldValue: React.FC<{
|
||||||
field: ItemField
|
field: ItemField
|
||||||
}> = ({ field }) => {
|
}> = ({ field }) => {
|
||||||
if (
|
if (field.type === FieldType.Password || field.designation === "password") {
|
||||||
field.type === FieldType.Password ||
|
|
||||||
(field.type === FieldType.Text && field.designation === "password")
|
|
||||||
) {
|
|
||||||
return <Password field={{ v: field.value } as any} />
|
return <Password field={{ v: field.value } as any} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Container>{field.value}</Container>
|
<TextView value={field.value!} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
|
import { memo } from "react"
|
||||||
import styled from "@emotion/styled"
|
import styled from "@emotion/styled"
|
||||||
import { cx } from "@emotion/css"
|
import { cx } from "@emotion/css"
|
||||||
import type { Item } from "opvault.js"
|
import type { Item } from "opvault.js"
|
||||||
|
import { AiFillStar } from "react-icons/ai"
|
||||||
import { CategoryIcon } from "./CategoryIcon"
|
import { CategoryIcon } from "./CategoryIcon"
|
||||||
|
import { useTranslate } from "../i18n"
|
||||||
|
import { ItemNoTitle } from "../styles"
|
||||||
|
|
||||||
interface ListProps {
|
interface ListProps {
|
||||||
items: Item[]
|
items: Item[]
|
||||||
@ -15,13 +19,14 @@ const List = styled.ol`
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
`
|
`
|
||||||
const ItemView = styled.li`
|
const ItemView = styled.li`
|
||||||
border-radius: 5px;
|
|
||||||
display: grid;
|
|
||||||
padding: 5px 15px;
|
|
||||||
transition: background-color 0.1s;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
display: grid;
|
||||||
grid-template-columns: 35px 1fr;
|
grid-template-columns: 35px 1fr;
|
||||||
|
padding: 5px 15px;
|
||||||
|
position: relative;
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
user-select: none;
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--hover-background);
|
background-color: var(--hover-background);
|
||||||
}
|
}
|
||||||
@ -36,36 +41,58 @@ const ItemTitle = styled.div`
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
`
|
`
|
||||||
|
|
||||||
const ItemDescription = styled.div`
|
const ItemDescription = styled.div`
|
||||||
font-size: 95%;
|
font-size: 95%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: 230px;
|
max-width: 230px;
|
||||||
|
&.empty {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
`
|
`
|
||||||
const Icon = styled(CategoryIcon)`
|
const Icon = styled(CategoryIcon)`
|
||||||
font-size: 1.5em;
|
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: React.FC<ListProps> = ({ items, onSelect, selected }) => (
|
export const ItemList = memo<ListProps>(({ items, onSelect, selected }) => {
|
||||||
<Container>
|
const t = useTranslate()
|
||||||
<List>
|
return (
|
||||||
{items.map(item => (
|
<Container>
|
||||||
<ItemView
|
<List>
|
||||||
key={item.uuid}
|
{items.map(item => (
|
||||||
onClick={() => onSelect(item)}
|
<ItemView
|
||||||
className={cx({
|
key={item.uuid}
|
||||||
selected: selected?.uuid === item.uuid,
|
onClick={() => onSelect(item)}
|
||||||
trashed: item.isDeleted,
|
className={cx({
|
||||||
})}
|
selected: selected?.uuid === item.uuid,
|
||||||
>
|
trashed: item.isDeleted,
|
||||||
<Icon fill="#FFF" category={item.category} />
|
})}
|
||||||
<div>
|
>
|
||||||
<ItemTitle>{item.overview.title!}</ItemTitle>
|
<div>
|
||||||
<ItemDescription>{item.overview.ainfo}</ItemDescription>
|
<Icon fill="#FFF" category={item.category} />
|
||||||
</div>
|
{!!item.fave && <Favorite />}
|
||||||
</ItemView>
|
</div>
|
||||||
))}
|
<div>
|
||||||
</List>
|
<ItemTitle>
|
||||||
</Container>
|
{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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import styled from "@emotion/styled"
|
import styled from "@emotion/styled"
|
||||||
import type { Item } from "opvault.js"
|
import type { Item } from "opvault.js"
|
||||||
import { useMemo } from "react"
|
import { useMemo, memo } from "react"
|
||||||
import { parseMonthYear } from "../utils"
|
import { parseMonthYear } from "../utils"
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
@ -12,7 +12,7 @@ const Container = styled.div`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
export const ItemWarning: React.FC<{ item: Item }> = ({ item }) => {
|
export const ItemWarning = memo<{ item: Item }>(({ item }) => {
|
||||||
const isExpired = useMemo(() => {
|
const isExpired = useMemo(() => {
|
||||||
const fields = item.details.sections?.flatMap(x => x.fields ?? [])
|
const fields = item.details.sections?.flatMap(x => x.fields ?? [])
|
||||||
if (!fields?.length) return false
|
if (!fields?.length) return false
|
||||||
@ -38,4 +38,4 @@ export const ItemWarning: React.FC<{ item: Item }> = ({ item }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
})
|
||||||
|
71
packages/web/src/components/Modal.tsx
Normal file
71
packages/web/src/components/Modal.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
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,3 +1,4 @@
|
|||||||
|
import { memo } from "react"
|
||||||
import styled from "@emotion/styled"
|
import styled from "@emotion/styled"
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
@ -16,8 +17,8 @@ const Title = styled.div`
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
`
|
`
|
||||||
|
|
||||||
export const TitleBar = () => (
|
export const TitleBar = memo(() => (
|
||||||
<Container>
|
<Container>
|
||||||
<Title>OPVault Viewer</Title>
|
<Title>OPVault Viewer</Title>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
))
|
||||||
|
69
packages/web/src/components/Toast.tsx
Normal file
69
packages/web/src/components/Toast.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
@ -1,22 +0,0 @@
|
|||||||
import { useCallback } from "react"
|
|
||||||
import { useTranslate } from "../i18n"
|
|
||||||
|
|
||||||
export const VaultPicker: React.FC<{
|
|
||||||
setHandle(handle: FileSystemDirectoryHandle): void
|
|
||||||
}> = ({ setHandle }) => {
|
|
||||||
const t = useTranslate()
|
|
||||||
|
|
||||||
const onClick = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const handle = await showDirectoryPicker()
|
|
||||||
setHandle(handle)
|
|
||||||
} catch (e) {
|
|
||||||
if ((e as Error).name === "AbortError") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
alert(e)
|
|
||||||
}
|
|
||||||
}, [setHandle])
|
|
||||||
|
|
||||||
return <button onClick={onClick}>{t.label_choose_a_vault}</button>
|
|
||||||
}
|
|
@ -2,6 +2,7 @@
|
|||||||
// Modules to control application life and create native browser window
|
// Modules to control application life and create native browser window
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import { app, BrowserWindow, Menu } from "electron"
|
import { app, BrowserWindow, Menu } from "electron"
|
||||||
|
import "./ipc"
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
@ -13,7 +14,7 @@ function createWindow() {
|
|||||||
icon: join(__dirname, "../512x512.png"),
|
icon: join(__dirname, "../512x512.png"),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
// preload: join(__dirname, "preload.js"),
|
preload: join(__dirname, "preload.js"),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
9
packages/web/src/electron/ipc-types.d.ts
vendored
Normal file
9
packages/web/src/electron/ipc-types.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export interface IPC {
|
||||||
|
showDirectoryPicker(): Promise<string | undefined>
|
||||||
|
pathExists(path: string): Promise<boolean>
|
||||||
|
readdir(path: string): Promise<string[]>
|
||||||
|
readBuffer(path: string): Promise<Uint8Array>
|
||||||
|
readFile(path: string): Promise<string>
|
||||||
|
writeFile(path: string, data: string): Promise<void>
|
||||||
|
isDirectory(path: string): Promise<boolean>
|
||||||
|
}
|
53
packages/web/src/electron/ipc.ts
Normal file
53
packages/web/src/electron/ipc.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import fs, { promises } from "fs"
|
||||||
|
import { ipcMain, dialog } from "electron"
|
||||||
|
import type { IPC } from "./ipc-types"
|
||||||
|
|
||||||
|
registerService({
|
||||||
|
async showDirectoryPicker() {
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
properties: ["openDirectory", "treatPackageAsDirectory"],
|
||||||
|
})
|
||||||
|
if (result.canceled || !result.filePaths.length) return
|
||||||
|
return result.filePaths[0]
|
||||||
|
},
|
||||||
|
|
||||||
|
async pathExists(_, path) {
|
||||||
|
return fs.existsSync(path)
|
||||||
|
},
|
||||||
|
|
||||||
|
async readBuffer(_, path) {
|
||||||
|
return promises.readFile(path)
|
||||||
|
},
|
||||||
|
|
||||||
|
async readFile(_, path) {
|
||||||
|
return promises.readFile(path, "utf-8")
|
||||||
|
},
|
||||||
|
|
||||||
|
async writeFile(_, path, content) {
|
||||||
|
await promises.writeFile(path, content)
|
||||||
|
},
|
||||||
|
|
||||||
|
async readdir(_, path) {
|
||||||
|
return promises.readdir(path)
|
||||||
|
},
|
||||||
|
|
||||||
|
async isDirectory(_, path) {
|
||||||
|
const stats = await promises.stat(path)
|
||||||
|
return stats.isDirectory()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens to `channel`, when a new message arrives `listener` would be called
|
||||||
|
* with `listener(event, ...args)`
|
||||||
|
*/
|
||||||
|
function registerService(listeners: {
|
||||||
|
[K in keyof IPC]: (
|
||||||
|
event: Electron.IpcMainEvent,
|
||||||
|
...args: Parameters<IPC[K]>
|
||||||
|
) => ReturnType<IPC[K]>
|
||||||
|
}) {
|
||||||
|
for (const [key, value] of Object.entries(listeners)) {
|
||||||
|
ipcMain.handle(`service-${key}`, value as any)
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import { contextBridge } from "electron"
|
import { contextBridge, ipcRenderer } from "electron"
|
||||||
import { nodeAdapter } from "opvault.js/src/adapters/node"
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("nodeAdapter", nodeAdapter)
|
contextBridge.exposeInMainWorld("ipcRenderer", {
|
||||||
|
invoke: ipcRenderer.invoke,
|
||||||
|
})
|
||||||
|
@ -1,18 +1,25 @@
|
|||||||
import { createContext, memo, useContext, useEffect, useMemo, useState } from "react"
|
import {
|
||||||
|
createContext,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
import texts from "./texts.yml"
|
import texts from "./texts.yml"
|
||||||
|
import { get, set, Key } from "../utils/localStorage"
|
||||||
|
|
||||||
type Keys = keyof typeof texts
|
const categories = Object.keys(texts)
|
||||||
|
|
||||||
const ALLOWED = new Set(["en", "fr"])
|
const ALLOWED = new Set(["en", "fr", "ja"])
|
||||||
const LOCALSTORAGE_KEY = "preferred-locale"
|
const SKIP_ITALIC = new Set(["zh_CN", "zh_TW", "ko", "ja"])
|
||||||
|
|
||||||
function getLocaleFromStorage() {
|
function getLocaleFromStorage() {
|
||||||
try {
|
const key = get(Key.PREFERRED_LOCALE)
|
||||||
const key = localStorage.getItem(LOCALSTORAGE_KEY)
|
if (key && ALLOWED.has(key)) {
|
||||||
if (key && ALLOWED.has(key)) {
|
return key
|
||||||
return key
|
}
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNavigatorLocale() {
|
function getNavigatorLocale() {
|
||||||
@ -38,35 +45,42 @@ export const useLocaleContext = () => useContext(LocaleContext)
|
|||||||
|
|
||||||
export function useTranslate() {
|
export function useTranslate() {
|
||||||
const { locale } = useContext(LocaleContext)
|
const { locale } = useContext(LocaleContext)
|
||||||
const t = useMemo(
|
const getter = useCallback(
|
||||||
() =>
|
(category: string, key: string) => {
|
||||||
new Proxy(
|
const obj = (texts as any)[category]
|
||||||
{},
|
if (
|
||||||
{
|
process.env.NODE_ENV === "development" &&
|
||||||
get(_, p: string) {
|
!Object.prototype.hasOwnProperty.call(obj, key)
|
||||||
if (
|
) {
|
||||||
process.env.NODE_ENV === "development" &&
|
throw new Error(`t.${key} does not exist.`)
|
||||||
!Object.prototype.hasOwnProperty.call(texts, p)
|
}
|
||||||
) {
|
return obj[key][locale]
|
||||||
throw new Error(`t.${p} does not exist.`)
|
},
|
||||||
}
|
|
||||||
return (texts as any)[p][locale]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
) as {
|
|
||||||
[key in Keys]: string
|
|
||||||
},
|
|
||||||
[locale]
|
[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
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LocaleContextProvider = memo(({ children }) => {
|
export const LocaleContextProvider = memo(({ children }) => {
|
||||||
const [locale, setLocale] = useState(getEnvLocale)
|
const [locale, setLocale] = useState(getEnvLocale)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
set(Key.PREFERRED_LOCALE, locale)
|
||||||
localStorage.setItem(LOCALSTORAGE_KEY, locale)
|
document.documentElement.lang = locale
|
||||||
} catch {}
|
|
||||||
}, [locale])
|
}, [locale])
|
||||||
const value = useMemo(() => ({ locale, setLocale }), [locale])
|
const value = useMemo(() => ({ locale, setLocale }), [locale])
|
||||||
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
|
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
|
||||||
|
@ -1,28 +1,250 @@
|
|||||||
# /* spellchecker: disable */
|
# /* spellchecker: disable */
|
||||||
label_choose_a_vault:
|
label:
|
||||||
en: Pick a vault here.
|
app_name:
|
||||||
fr: Choisir un coffre ici.
|
en: OPVault Viewer
|
||||||
|
fr: Lecteur de coffre OPVault
|
||||||
|
ja: OPVault ビューワー
|
||||||
|
|
||||||
label_no_vault_selected:
|
choose_a_vault:
|
||||||
en: No vault is selected.
|
en: Pick a vault
|
||||||
fr: Aucun coffre n’est sélectionné.
|
fr: Choisir un coffre
|
||||||
|
ja: 保管庫を選ぶ
|
||||||
|
|
||||||
label_last_updated:
|
no_vault_selected:
|
||||||
en: Last Updated
|
en: No vault is selected.
|
||||||
fr: Dernière modification
|
fr: Aucun coffre n’est sélectionné.
|
||||||
|
ja: 選択した保管庫はありません。
|
||||||
|
|
||||||
label_created_at:
|
last_updated:
|
||||||
en: Created At
|
en: Last Updated
|
||||||
fr: Créé
|
fr: Dernière modification
|
||||||
|
ja: 更新日時
|
||||||
|
|
||||||
noun_vault:
|
created_at:
|
||||||
en: vault
|
en: Created At
|
||||||
fr: coffre
|
fr: Créé
|
||||||
|
ja: 作成日時
|
||||||
|
|
||||||
action_lock:
|
password_placeholder:
|
||||||
en: Lock
|
en: Master Password
|
||||||
fr: Vérouiller
|
fr: Mot de passe principal
|
||||||
|
ja: マスターパスワード
|
||||||
|
|
||||||
action_unlock:
|
username:
|
||||||
en: Unlock
|
en: Username
|
||||||
fr: Déverouiller
|
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: クリップボードへコピーしました
|
||||||
|
@ -10,36 +10,50 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen", "Ubuntu",
|
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen",
|
||||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
"Cantarell", "Droid Sans", "Helvetica Neue", "Noto Sans CJK JP", sans-serif;
|
||||||
|
}
|
||||||
|
:root {
|
||||||
|
--page-background: #fff;
|
||||||
--color: #000;
|
--color: #000;
|
||||||
--titlebar-height: 46px;
|
--titlebar-height: 46px;
|
||||||
--titlebar-height: 0px;
|
--titlebar-height: 0px;
|
||||||
--label-background: #ddd;
|
--label-background: #ddd;
|
||||||
--selected-background: #c9c9c9;
|
--selected-background: #d5d5d5;
|
||||||
--hover-background: #ddd;
|
--hover-background: #ddd;
|
||||||
--monospace: D2Coding, source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
--border-color: #e3e3e3;
|
||||||
|
--monospace: D2Coding, "source-code-pro", Menlo, Monaco, Consolas, "Courier New",
|
||||||
monospace;
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root.mac {
|
||||||
|
--page-background: #f7f7f7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
background-color: var(--page-background);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body {
|
:root {
|
||||||
color: #fff;
|
|
||||||
--color: #fff;
|
--color: #fff;
|
||||||
--label-background: #353535;
|
--label-background: #353535;
|
||||||
--selected-background: #353535;
|
--selected-background: #353535;
|
||||||
--selected-background: #15539e;
|
--border-color: #333;
|
||||||
--hover-background: #222;
|
--hover-background: #222;
|
||||||
|
--page-background: #292929;
|
||||||
}
|
}
|
||||||
#root {
|
body {
|
||||||
background-color: #292929;
|
color: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,24 +66,51 @@ input {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
input[type="search"],
|
@mixin input {
|
||||||
input[type="input"],
|
|
||||||
input[type="password"] {
|
|
||||||
@include scheme(background-color, #fff, #2d2d2d);
|
@include scheme(background-color, #fff, #2d2d2d);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid;
|
border: 1px solid #fff;
|
||||||
@include scheme(border-color, #cdc7c2, #1b1b1b);
|
@include scheme(border-color, #cdc7c2, #1b1b1b);
|
||||||
color: inherit;
|
|
||||||
outline: none;
|
|
||||||
padding: 7px 8px;
|
|
||||||
transition: 0.1s;
|
transition: 0.1s;
|
||||||
&:focus {
|
&:focus {
|
||||||
@include scheme(border-color, #3584e480, #15539e);
|
@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,
|
button,
|
||||||
select {
|
select,
|
||||||
|
.button {
|
||||||
@include scheme(background-color, #f6f5f4, #333);
|
@include scheme(background-color, #f6f5f4, #333);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
@ -97,17 +138,33 @@ select {
|
|||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
// #region color
|
||||||
width: 7px;
|
.color-primary,
|
||||||
|
.color-secondary,
|
||||||
|
.color-info,
|
||||||
|
.color-danger {
|
||||||
|
@include scheme(color, #fff, #fafafa);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
.color-success,
|
||||||
background: transparent;
|
.color-warning {
|
||||||
|
@include scheme(color, #000, #111);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
.color-primary {
|
||||||
@include scheme(background, #8883, #6663);
|
@include scheme(background-color, #0b5ed7, #375a7f);
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
.color-secondary {
|
||||||
transition: 0.1s;
|
@include scheme(background-color, #6c757d, #626262);
|
||||||
@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,14 +2,23 @@ import React from "react"
|
|||||||
import { render } from "react-dom"
|
import { render } from "react-dom"
|
||||||
import { App } from "./App"
|
import { App } from "./App"
|
||||||
import { LocaleContextProvider } from "./i18n"
|
import { LocaleContextProvider } from "./i18n"
|
||||||
|
import { SideEffect } from "./SideEffect"
|
||||||
|
import { Toast } from "./components/Toast"
|
||||||
import "./index.scss"
|
import "./index.scss"
|
||||||
|
|
||||||
render(
|
if (navigator.platform === "MacIntel") {
|
||||||
|
document.documentElement.classList.add("mac")
|
||||||
|
}
|
||||||
|
|
||||||
|
const Root: React.FC = () => (
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
{/* <TitleBar /> */}
|
{/* <TitleBar /> */}
|
||||||
<LocaleContextProvider>
|
<LocaleContextProvider>
|
||||||
|
<SideEffect />
|
||||||
<App />
|
<App />
|
||||||
|
<Toast />
|
||||||
</LocaleContextProvider>
|
</LocaleContextProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>
|
||||||
document.getElementById("root")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
render(<Root />, document.getElementById("app"))
|
||||||
|
3
packages/web/src/modules.d.ts
vendored
Normal file
3
packages/web/src/modules.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
declare module "react-idle-timer/modern" {
|
||||||
|
export * from "react-idle-timer/dist/modern"
|
||||||
|
}
|
@ -1,23 +0,0 @@
|
|||||||
import styled from "@emotion/styled"
|
|
||||||
import { VaultPicker } from "../components/VaultPicker"
|
|
||||||
import { useTranslate } from "../i18n"
|
|
||||||
|
|
||||||
const Container = styled.div`
|
|
||||||
padding: 100px;
|
|
||||||
text-align: center;
|
|
||||||
`
|
|
||||||
const Info = styled.div`
|
|
||||||
margin: 10px;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const PickOPVault: React.FC<{
|
|
||||||
setHandle(handle: FileSystemDirectoryHandle): void
|
|
||||||
}> = ({ setHandle }) => {
|
|
||||||
const t = useTranslate()
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<VaultPicker setHandle={setHandle} />
|
|
||||||
<Info>{t.label_no_vault_selected}</Info>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
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,134 +1,103 @@
|
|||||||
import styled from "@emotion/styled"
|
import styled from "@emotion/styled"
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import type { Vault, Item } from "opvault.js"
|
import type { Vault, Item } from "opvault.js"
|
||||||
import { Category } from "opvault.js"
|
import { AiOutlineStar } from "react-icons/ai"
|
||||||
import { IoSearch } from "react-icons/io5"
|
import { FiLock } from "react-icons/fi"
|
||||||
import { ItemList } from "../components/ItemList"
|
import { Si1Password } from "react-icons/si"
|
||||||
import { ItemView } from "../components/Item"
|
import { BsGear } from "react-icons/bs"
|
||||||
import { reactIconClass } from "../components/CategoryIcon"
|
|
||||||
import { useTranslate } from "../i18n/index"
|
import { useTranslate } from "../i18n/index"
|
||||||
|
import { Settings } from "../settings"
|
||||||
|
import { FilteredVaultView } from "../components/FilteredVaultView"
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
height: calc(100vh - var(--titlebar-height));
|
height: calc(100vh - var(--titlebar-height));
|
||||||
`
|
`
|
||||||
const ListContainer = styled.div`
|
const TabContainer = styled.div`
|
||||||
width: 300px;
|
border-right: 1px solid var(--border-color);
|
||||||
margin-right: 10px;
|
display: flex;
|
||||||
overflow-y: scroll;
|
flex-direction: column;
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
width: 54px;
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
background: #202020;
|
background: #222;
|
||||||
|
border-right-color: transparent;
|
||||||
|
}
|
||||||
|
&&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
const ItemContainer = styled.div`
|
const TabButton = styled.button<{ active?: boolean }>`
|
||||||
width: calc(100% - 300px);
|
align-items: center;
|
||||||
overflow: hidden;
|
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`
|
const TabContainerMain = styled.div`
|
||||||
text-align: center;
|
flex-grow: 1;
|
||||||
margin: 10px 0;
|
|
||||||
position: relative;
|
|
||||||
`
|
`
|
||||||
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 }> = ({
|
export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
||||||
vault,
|
vault,
|
||||||
onLock,
|
onLock,
|
||||||
}) => {
|
}) => {
|
||||||
const t = useTranslate()
|
const [tab, setTab] = useState(Tab.All)
|
||||||
const [items, setItems] = useState<Item[]>(() => [])
|
const [items, setItems] = useState<Item[]>(() => [])
|
||||||
const [item, setItem] = useState<Item>()
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
const [sortBy, setSortBy] = useState(SortBy.Name)
|
const t = useTranslate()
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
setItem(undefined)
|
|
||||||
arrayFrom(vault.values()).then(setItems)
|
arrayFrom(vault.values()).then(setItems)
|
||||||
}, [vault])
|
}, [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 (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<ListContainer>
|
<TabContainer>
|
||||||
<div
|
<TabContainerMain>
|
||||||
style={{
|
<TabButton active={tab === Tab.All} onClick={() => setTab(Tab.All)}>
|
||||||
margin: "10px 10px",
|
<Si1Password />
|
||||||
}}
|
</TabButton>
|
||||||
>
|
<TabButton active={tab === Tab.Favorites} onClick={() => setTab(Tab.Favorites)}>
|
||||||
<button onClick={onLock}>{t.action_lock}</button>
|
<AiOutlineStar />
|
||||||
</div>
|
</TabButton>
|
||||||
<SearchContainer>
|
</TabContainerMain>
|
||||||
<SearchInput
|
<TabButton onClick={onLock} title={t.action.lock}>
|
||||||
type="search"
|
<FiLock />
|
||||||
value={search}
|
</TabButton>
|
||||||
onChange={e => setSearch(e.currentTarget.value)}
|
<TabButton onClick={() => setShowSettings(true)} title={t.label.settings}>
|
||||||
/>
|
<BsGear />
|
||||||
<SearchIcon className={reactIconClass} />
|
</TabButton>
|
||||||
</SearchContainer>
|
</TabContainer>
|
||||||
<SortContainer>
|
|
||||||
<select
|
{tab === Tab.All ? (
|
||||||
style={{ width: "100%" }}
|
<FilteredVaultView items={items} />
|
||||||
value={sortBy}
|
) : tab === Tab.Favorites ? (
|
||||||
onChange={e => setSortBy(+e.currentTarget.value)}
|
<FavoriteItemsView items={items} />
|
||||||
>
|
) : null}
|
||||||
<option value={SortBy.Name}>Sort by Name</option>
|
|
||||||
<option value={SortBy.CreatedAt}>Sort by Created Time</option>
|
<Settings show={showSettings} onHide={() => setShowSettings(false)} />
|
||||||
<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>
|
</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>) {
|
async function arrayFrom<T>(generator: AsyncGenerator<T, void, unknown>) {
|
||||||
const list: T[] = []
|
const list: T[] = []
|
||||||
for await (const value of generator) {
|
for await (const value of generator) {
|
||||||
@ -137,8 +106,7 @@ async function arrayFrom<T>(generator: AsyncGenerator<T, void, unknown>) {
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringCompare(search: string, source?: string) {
|
enum Tab {
|
||||||
if (!search) return true
|
All,
|
||||||
if (!source) return false
|
Favorites,
|
||||||
return source.toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
|
||||||
}
|
}
|
||||||
|
148
packages/web/src/pages/VaultPicker/Picker.tsx
Normal file
148
packages/web/src/pages/VaultPicker/Picker.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { css } from "@emotion/css"
|
||||||
|
import { useCallback, useMemo, memo, useState } from "react"
|
||||||
|
import { Si1Password } from "react-icons/si"
|
||||||
|
import { FaFolderOpen } from "react-icons/fa"
|
||||||
|
import { ImCross } from "react-icons/im"
|
||||||
|
import { MdClearAll } from "react-icons/md"
|
||||||
|
import { BsGear, BsInfoCircle } from "react-icons/bs"
|
||||||
|
import { openDirectory } from "../../utils/ipc-adapter"
|
||||||
|
import { useTranslate } from "../../i18n"
|
||||||
|
import { Key, useStorage } from "../../utils/localStorage"
|
||||||
|
import { Settings } from "../../settings"
|
||||||
|
import { About } from "../../about"
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
padding: 100px;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
`
|
||||||
|
const List = styled.ul`
|
||||||
|
list-style-type: none;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
`
|
||||||
|
const Item = styled.li`
|
||||||
|
align-items: center;
|
||||||
|
cursor: default;
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 10px;
|
||||||
|
user-select: none;
|
||||||
|
&:not(:hover):not(:active) {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const icon = css`
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-right: 10px;
|
||||||
|
`
|
||||||
|
const Text = styled.div`
|
||||||
|
flex-grow: 1;
|
||||||
|
`
|
||||||
|
const Hr = styled.hr`
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
`
|
||||||
|
const DeleteItem = styled(ImCross)`
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.7em;
|
||||||
|
opacity: 0;
|
||||||
|
${Item}:hover & {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const NonCriticalPath = styled.span`
|
||||||
|
opacity: 0.4;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Path = memo(({ children }: { children: string }) => {
|
||||||
|
const segments = useMemo(() => children.split("/"), [children])
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{segments.map((seg, i, { length }) =>
|
||||||
|
i < length - 1 ? (
|
||||||
|
<NonCriticalPath key={i}>{seg}/</NonCriticalPath>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{seg}</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const enum Modal {
|
||||||
|
None,
|
||||||
|
Settings,
|
||||||
|
About,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PickOPVault: React.FC<{
|
||||||
|
setPath(path: string): void
|
||||||
|
}> = ({ setPath }) => {
|
||||||
|
const t = useTranslate()
|
||||||
|
const [modal, setModal] = useState(Modal.None)
|
||||||
|
const [list, $setList] = useStorage(Key.RECENTLY_OPENED_VAULTS)
|
||||||
|
|
||||||
|
const clearHistory = useCallback(() => {
|
||||||
|
$setList([])
|
||||||
|
}, [$setList])
|
||||||
|
|
||||||
|
const setList = useCallback(
|
||||||
|
(fn: (value: Set<string>) => void) => {
|
||||||
|
$setList(list => {
|
||||||
|
const set = new Set(list)
|
||||||
|
fn(set)
|
||||||
|
return Array.from(set)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[$setList]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onClick = useCallback(async () => {
|
||||||
|
const path = await openDirectory()
|
||||||
|
if (path) {
|
||||||
|
setPath(path)
|
||||||
|
setList(set => set.add(path))
|
||||||
|
}
|
||||||
|
}, [setPath, setList])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<List>
|
||||||
|
<Item className="button" onClick={onClick}>
|
||||||
|
<FaFolderOpen className={icon} />
|
||||||
|
{t.label.choose_a_vault}…
|
||||||
|
</Item>
|
||||||
|
{list.map((item, i) => (
|
||||||
|
<Item className="button" onClick={() => setPath(item)} key={i}>
|
||||||
|
<Si1Password className={icon} />
|
||||||
|
<Text>
|
||||||
|
<Path>{item}</Path>
|
||||||
|
</Text>
|
||||||
|
<DeleteItem onClick={() => setList(list => list.delete(item))} />
|
||||||
|
</Item>
|
||||||
|
))}
|
||||||
|
{list.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Hr />
|
||||||
|
<Item className="button" onClick={() => clearHistory()}>
|
||||||
|
<MdClearAll className={icon} />
|
||||||
|
{t.action.clear_history}
|
||||||
|
</Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Item className="button" onClick={() => setModal(Modal.Settings)}>
|
||||||
|
<BsGear className={icon} />
|
||||||
|
{t.label.settings}
|
||||||
|
</Item>
|
||||||
|
<Item className="button" onClick={() => setModal(Modal.About)}>
|
||||||
|
<BsInfoCircle className={icon} />
|
||||||
|
{t.label.about_app}
|
||||||
|
</Item>
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Settings show={modal === Modal.Settings} onHide={() => setModal(Modal.None)} />
|
||||||
|
<About show={modal === Modal.About} onHide={() => setModal(Modal.None)} />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
148
packages/web/src/pages/VaultPicker/Unlock.tsx
Normal file
148
packages/web/src/pages/VaultPicker/Unlock.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
73
packages/web/src/pages/VaultPicker/index.tsx
Normal file
73
packages/web/src/pages/VaultPicker/index.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
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
|
||||||
|
}
|
87
packages/web/src/settings/index.tsx
Normal file
87
packages/web/src/settings/index.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
39
packages/web/src/styles.ts
Normal file
39
packages/web/src/styles.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
31
packages/web/src/utils/ipc-adapter.ts
Normal file
31
packages/web/src/utils/ipc-adapter.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Buffer } from "buffer"
|
||||||
|
import type { IAdapter } from "opvault.js/src/adapters"
|
||||||
|
import type { IPC } from "../electron/ipc-types"
|
||||||
|
import { memoize } from "./memoize"
|
||||||
|
|
||||||
|
declare const ipcRenderer: Electron.IpcRenderer
|
||||||
|
|
||||||
|
export async function openDirectory() {
|
||||||
|
return ipc.showDirectoryPicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const electronAdapter: IAdapter = {
|
||||||
|
fs: {
|
||||||
|
exists: path => ipc.pathExists(path),
|
||||||
|
readBuffer: path => ipc.readBuffer(path).then(Buffer.from),
|
||||||
|
readFile: path => ipc.readFile(path),
|
||||||
|
readdir: path => ipc.readdir(path),
|
||||||
|
writeFile: (path, data) => ipc.writeFile(path, data),
|
||||||
|
isDirectory: path => ipc.isDirectory(path),
|
||||||
|
},
|
||||||
|
subtle: crypto.subtle,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipc = new Proxy<IPC>({} as any, {
|
||||||
|
get: memoize(
|
||||||
|
(_, channel: string) =>
|
||||||
|
(...args: any[]) =>
|
||||||
|
ipcRenderer.invoke(`service-${channel}`, ...args),
|
||||||
|
(_, name) => name
|
||||||
|
),
|
||||||
|
})
|
76
packages/web/src/utils/localStorage.ts
Normal file
76
packages/web/src/utils/localStorage.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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, [])
|
18
packages/web/src/utils/memoize.ts
Normal file
18
packages/web/src/utils/memoize.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export function memoize<T extends (...args: any[]) => any, K>(
|
||||||
|
fn: T,
|
||||||
|
getKey: (...args: Parameters<T>) => K
|
||||||
|
): T {
|
||||||
|
const map = new Map<K, ReturnType<T>>()
|
||||||
|
function memoized(this: any, ...args: Parameters<T>): ReturnType<T> {
|
||||||
|
const key = getKey(...args)
|
||||||
|
if (map.has(key)) {
|
||||||
|
return map.get(key)!
|
||||||
|
} else {
|
||||||
|
const value = fn.apply(this, args)
|
||||||
|
map.set(key, value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.defineProperty(memoized, "name", { value: fn.name })
|
||||||
|
return memoized as any
|
||||||
|
}
|
4
packages/web/src/vite-env.d.ts
vendored
4
packages/web/src/vite-env.d.ts
vendored
@ -1 +1,5 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface Array<T> {
|
||||||
|
filter(predicate: BooleanConstructor): Exclude<T, null | undefined | 0 | "" | false>[]
|
||||||
|
}
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite"
|
||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react"
|
||||||
import yaml from "@rollup/plugin-yaml"
|
import yaml from "@rollup/plugin-yaml"
|
||||||
|
import babel from "./scripts/vite-babel"
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: "./",
|
base: "./",
|
||||||
plugins: [react(), yaml()],
|
plugins: [babel(), react(), yaml()],
|
||||||
define: {
|
define: {
|
||||||
global: "globalThis",
|
global: "globalThis",
|
||||||
"process.browser": "true",
|
"process.browser": "true",
|
||||||
@ -13,6 +14,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: "dist/web",
|
outDir: "dist/web",
|
||||||
|
rollupOptions: {
|
||||||
|
external: ["fs", ""],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
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 { describe, it, beforeEach } from "mocha";
|
||||||
import { expect } from "chai";
|
import { expect } from "chai";
|
||||||
|
|
||||||
import type { Vault } from "../packages/opvault.js/index";
|
import type { Vault } from "../packages/opvault.js/src/index";
|
||||||
import { OnePassword } from "../packages/opvault.js/index";
|
import { OnePassword } from "../packages/opvault.js/src/index";
|
||||||
|
|
||||||
describe("OnePassword", () => {
|
describe("OnePassword", () => {
|
||||||
const freddy = resolve(__dirname, "../freddy-2013-12-04.opvault");
|
const freddy = resolve(__dirname, "../freddy-2013-12-04.opvault");
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { describe, it } from "mocha";
|
import { describe, it } from "mocha";
|
||||||
import { expect } from "chai";
|
import { expect } from "chai";
|
||||||
|
|
||||||
import { WeakValueMap } from "../packages/opvault.js/weakMap";
|
import { WeakValueMap } from "../packages/opvault.js/src/weakMap";
|
||||||
|
|
||||||
declare const gc: () => void;
|
declare const gc: () => void;
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user