feat: deprecated editors (#1166)
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"modules": false,
|
||||
"targets": "defaults"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@babel/preset-react",
|
||||
{
|
||||
"runtime": "automatic"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-transform-runtime"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
> 0.25%
|
||||
not dead
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/**
|
||||
dist/**
|
||||
webpack.*
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true
|
||||
},
|
||||
"extends": [
|
||||
"@standardnotes/eslint-config-extensions",
|
||||
"plugin:react/recommended"
|
||||
],
|
||||
"parser": "@babel/eslint-parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 11,
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"react"
|
||||
],
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"react/jsx-uses-react": "off",
|
||||
"react/react-in-jsx-scope": "off"
|
||||
}
|
||||
}
|
||||
53
packages/components/src/Packages/Editors/org.standardnotes.token-vault/.gitignore
vendored
Normal file
53
packages/components/src/Packages/Editors/org.standardnotes.token-vault/.gitignore
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Dependency directory
|
||||
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
|
||||
node_modules
|
||||
|
||||
## building output directory
|
||||
build
|
||||
|
||||
# OSX
|
||||
.DS_Store
|
||||
|
||||
# Packaged and compiled files
|
||||
.eslintcache
|
||||
|
||||
# Env configuration
|
||||
.env
|
||||
|
||||
# Editor
|
||||
.idea
|
||||
|
||||
ext.json
|
||||
dist
|
||||
@@ -0,0 +1 @@
|
||||
ext.json
|
||||
@@ -0,0 +1,74 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [2.1.2](https://github.com/standardnotes/app/compare/@standardnotes/authenticator@2.1.2-alpha.0...@standardnotes/authenticator@2.1.2) (2022-06-23)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/authenticator
|
||||
|
||||
## [2.1.2-alpha.0](https://github.com/standardnotes/app/compare/@standardnotes/authenticator@2.1.1...@standardnotes/authenticator@2.1.2-alpha.0) (2022-06-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **components:** disable minification in themes build due to limitations in mobile css parser ([#1143](https://github.com/standardnotes/app/issues/1143)) ([2d069fd](https://github.com/standardnotes/app/commit/2d069fd4bdca95d857ba20b5f3c946db1ae1735a))
|
||||
|
||||
## [2.1.1](https://github.com/standardnotes/app/compare/@standardnotes/authenticator@2.1.1-alpha.0...@standardnotes/authenticator@2.1.1) (2022-06-23)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/authenticator
|
||||
|
||||
## [2.1.1-alpha.0](https://github.com/standardnotes/app/compare/@standardnotes/authenticator@2.1.0...@standardnotes/authenticator@2.1.1-alpha.0) (2022-06-23)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/authenticator
|
||||
|
||||
# [2.1.0](https://github.com/standardnotes/app/compare/@standardnotes/authenticator@2.1.0-alpha.0...@standardnotes/authenticator@2.1.0) (2022-06-22)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/authenticator
|
||||
|
||||
# [2.1.0-alpha.0](https://github.com/standardnotes/app/compare/@standardnotes/authenticator@2.0.14...@standardnotes/authenticator@2.1.0-alpha.0) (2022-06-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* components scripts ([#1136](https://github.com/standardnotes/app/issues/1136)) ([e80b4d0](https://github.com/standardnotes/app/commit/e80b4d0ffad495c758b593c30e1c4c754dda9b7e))
|
||||
|
||||
### Features
|
||||
|
||||
* optional secret field ([#1115](https://github.com/standardnotes/app/issues/1115)) ([7c0938b](https://github.com/standardnotes/app/commit/7c0938b877f21787dd53fbf46e591487ef02a1c8))
|
||||
|
||||
## [2.0.14](https://github.com/standardnotes/app/compare/@standardnotes/authenticator@2.0.14-alpha.0...@standardnotes/authenticator@2.0.14) (2022-06-18)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/authenticator
|
||||
|
||||
## [2.0.14-alpha.0](https://github.com/standardnotes/app/compare/@standardnotes/authenticator@2.0.13...@standardnotes/authenticator@2.0.14-alpha.0) (2022-06-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* plus editor icons ([#1120](https://github.com/standardnotes/app/issues/1120)) ([ba65948](https://github.com/standardnotes/app/commit/ba65948364a3fca7bfa5005c56802102c73ccd99))
|
||||
|
||||
## 2.0.13 (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/authenticator
|
||||
|
||||
## 2.0.12 (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/authenticator
|
||||
|
||||
## [2.0.11](https://github.com/standardnotes/app/compare/@standardnotes/authenticator@2.0.11-alpha.3...@standardnotes/authenticator@2.0.11) (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/authenticator
|
||||
|
||||
## [2.0.11-alpha.3](https://github.com/standardnotes/app/compare/@standardnotes/authenticator@2.0.11-alpha.2...@standardnotes/authenticator@2.0.11-alpha.3) (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/authenticator
|
||||
|
||||
## 2.0.11-alpha.2 (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/authenticator
|
||||
|
||||
## 2.0.11-alpha.1 (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/authenticator
|
||||
|
||||
## 2.0.11-alpha.0 (2022-06-15)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/authenticator
|
||||
@@ -0,0 +1,7 @@
|
||||
## LICENSE
|
||||
|
||||
As of version 2.0, TokenVault houses a public-source, but not open-source presence. This means you are free to browse the code and even use the code _privately_, but you may not redistribute, repackage, or otherwise use its public distributable/release assets on GitHub, for free or for profit.
|
||||
|
||||
For more information, read [this blog post](https://blog.standardnotes.com/why-tokenvault-is-going-public-source/).
|
||||
|
||||
Previous versions of TokenVault retain the license they were released with.
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg width="10px" height="16px" viewBox="0 0 10 16" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||
<g transform="translate(-617.000000, -246.000000)">
|
||||
<g transform="translate(100.000000, 100.000000)">
|
||||
<g transform="translate(510.000000, 142.000000)">
|
||||
<g>
|
||||
<polygon points="0 0 24 0 24 24 0 24"></polygon>
|
||||
<path d="M11,18 C11,19.1 10.1,20 9,20 C7.9,20 7,19.1 7,18 C7,16.9 7.9,16 9,16 C10.1,16 11,16.9 11,18 Z M9,10 C7.9,10 7,10.9 7,12 C7,13.1 7.9,14 9,14 C10.1,14 11,13.1 11,12 C11,10.9 10.1,10 9,10 Z M9,4 C7.9,4 7,4.9 7,6 C7,7.1 7.9,8 9,8 C10.1,8 11,7.1 11,6 C11,4.9 10.1,4 9,4 Z M15,8 C16.1,8 17,7.1 17,6 C17,4.9 16.1,4 15,4 C13.9,4 13,4.9 13,6 C13,7.1 13.9,8 15,8 Z M15,10 C13.9,10 13,10.9 13,12 C13,13.1 13.9,14 15,14 C16.1,14 17,13.1 17,12 C17,10.9 16.1,10 15,10 Z M15,16 C13.9,16 13,16.9 13,18 C13,19.1 13.9,20 15,20 C16.1,20 17,19.1 17,18 C17,16.9 16.1,16 15,16 Z" fill="currentColor"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5833 8C12.2518 8 11.9339 7.8683 11.6995 7.63388C11.465 7.39946 11.3333 7.08152 11.3333 6.75C11.3333 6.41848 11.465 6.10054 11.6995 5.86612C11.9339 5.6317 12.2518 5.5 12.5833 5.5C12.9149 5.5 13.2328 5.6317 13.4672 5.86612C13.7016 6.10054 13.8333 6.41848 13.8333 6.75C13.8333 7.08152 13.7016 7.39946 13.4672 7.63388C13.2328 7.8683 12.9149 8 12.5833 8ZM10.0833 4.66667C9.75181 4.66667 9.43387 4.53497 9.19945 4.30055C8.96503 4.06613 8.83333 3.74819 8.83333 3.41667C8.83333 3.08515 8.96503 2.7672 9.19945 2.53278C9.43387 2.29836 9.75181 2.16667 10.0833 2.16667C10.4149 2.16667 10.7328 2.29836 10.9672 2.53278C11.2016 2.7672 11.3333 3.08515 11.3333 3.41667C11.3333 3.74819 11.2016 4.06613 10.9672 4.30055C10.7328 4.53497 10.4149 4.66667 10.0833 4.66667ZM5.91667 4.66667C5.58515 4.66667 5.2672 4.53497 5.03278 4.30055C4.79836 4.06613 4.66667 3.74819 4.66667 3.41667C4.66667 3.08515 4.79836 2.7672 5.03278 2.53278C5.2672 2.29836 5.58515 2.16667 5.91667 2.16667C6.24819 2.16667 6.56613 2.29836 6.80055 2.53278C7.03497 2.7672 7.16667 3.08515 7.16667 3.41667C7.16667 3.74819 7.03497 4.06613 6.80055 4.30055C6.56613 4.53497 6.24819 4.66667 5.91667 4.66667ZM3.41667 8C3.08515 8 2.7672 7.8683 2.53278 7.63388C2.29836 7.39946 2.16667 7.08152 2.16667 6.75C2.16667 6.41848 2.29836 6.10054 2.53278 5.86612C2.7672 5.6317 3.08515 5.5 3.41667 5.5C3.74819 5.5 4.06613 5.6317 4.30055 5.86612C4.53497 6.10054 4.66667 6.41848 4.66667 6.75C4.66667 7.08152 4.53497 7.39946 4.30055 7.63388C4.06613 7.8683 3.74819 8 3.41667 8ZM8 0.5C6.01088 0.5 4.10322 1.29018 2.6967 2.6967C1.29018 4.10322 0.5 6.01088 0.5 8C0.5 9.98912 1.29018 11.8968 2.6967 13.3033C4.10322 14.7098 6.01088 15.5 8 15.5C8.33152 15.5 8.64946 15.3683 8.88388 15.1339C9.1183 14.8995 9.25 14.5815 9.25 14.25C9.25 13.925 9.125 13.6333 8.925 13.4167C8.73333 13.1917 8.60833 12.9 8.60833 12.5833C8.60833 12.2518 8.74003 11.9339 8.97445 11.6995C9.20887 11.465 9.52681 11.3333 9.85833 11.3333H11.3333C12.4384 11.3333 13.4982 10.8943 14.2796 10.1129C15.061 9.33154 15.5 8.27174 15.5 7.16667C15.5 3.48333 12.1417 0.5 8 0.5Z" fill="#FFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,37 @@
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12px" height="10px"
|
||||
viewBox="0 0 300 300"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<g fill="currentColor">
|
||||
<path
|
||||
d="M159.365,23.736v-10c0-5.523-4.477-10-10-10H10c-5.523,0-10,4.477-10,10v10c0,5.523,4.477,10,10,10h139.365
|
||||
C154.888,33.736,159.365,29.259,159.365,23.736z"
|
||||
/>
|
||||
<path
|
||||
d="M130.586,66.736H10c-5.523,0-10,4.477-10,10v10c0,5.523,4.477,10,10,10h120.586c5.523,0,10-4.477,10-10v-10
|
||||
C140.586,71.213,136.109,66.736,130.586,66.736z"
|
||||
/>
|
||||
<path
|
||||
d="M111.805,129.736H10c-5.523,0-10,4.477-10,10v10c0,5.523,4.477,10,10,10h101.805c5.523,0,10-4.477,10-10v-10
|
||||
C121.805,134.213,117.328,129.736,111.805,129.736z"
|
||||
/>
|
||||
<path
|
||||
d="M93.025,199.736H10c-5.523,0-10,4.477-10,10v10c0,5.523,4.477,10,10,10h83.025c5.522,0,10-4.477,10-10v-10
|
||||
C103.025,204.213,98.548,199.736,93.025,199.736z"
|
||||
/>
|
||||
<path
|
||||
d="M74.244,262.736H10c-5.523,0-10,4.477-10,10v10c0,5.523,4.477,10,10,10h64.244c5.522,0,10-4.477,10-10v-10
|
||||
C84.244,267.213,79.767,262.736,74.244,262.736z"
|
||||
/>
|
||||
<path
|
||||
d="M298.29,216.877l-7.071-7.071c-1.875-1.875-4.419-2.929-7.071-2.929c-2.652,0-5.196,1.054-7.072,2.929l-34.393,34.393
|
||||
V18.736c0-5.523-4.477-10-10-10h-10c-5.523,0-10,4.477-10,10v225.462l-34.393-34.393c-1.876-1.875-4.419-2.929-7.071-2.929
|
||||
c-2.652,0-5.196,1.054-7.071,2.929l-7.072,7.071c-3.904,3.905-3.904,10.237,0,14.142l63.536,63.536
|
||||
c1.953,1.953,4.512,2.929,7.071,2.929c2.559,0,5.119-0.976,7.071-2.929l63.536-63.536
|
||||
C302.195,227.113,302.195,220.781,298.29,216.877z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,202 @@
|
||||
import AuthMenu from '@Components/AuthMenu'
|
||||
import CountdownPie from '@Components/CountdownPie'
|
||||
import { totp } from '@Lib/otp'
|
||||
import { getEntryColor, getVarColorForContrast, hexColorToRGB } from '@Lib/utils'
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import DragIndicator from '../assets/svg/drag-indicator.svg'
|
||||
|
||||
export default class AuthEntry extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
token: '',
|
||||
timeLeft: 0,
|
||||
entryStyle: {
|
||||
color: '',
|
||||
backgroundColor: '',
|
||||
},
|
||||
}
|
||||
|
||||
this.updateToken()
|
||||
}
|
||||
|
||||
getTimeLeft() {
|
||||
const seconds = new Date().getSeconds()
|
||||
return seconds > 29 ? 60 - seconds : 30 - seconds
|
||||
}
|
||||
|
||||
updateToken = async () => {
|
||||
const { secret } = this.props.entry
|
||||
if (!secret) {
|
||||
return
|
||||
}
|
||||
|
||||
const token = await totp.gen(secret)
|
||||
const timeLeft = this.getTimeLeft()
|
||||
this.setState({
|
||||
token,
|
||||
timeLeft,
|
||||
})
|
||||
|
||||
this.timer = setTimeout(this.updateToken, timeLeft * 1000)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateEntryStyle()
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// If the secret changed make sure to recalculate token
|
||||
if (prevProps.entry.secret !== this.props.entry.secret) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(this.updateToken, 0)
|
||||
}
|
||||
|
||||
if (prevProps.lastUpdated !== this.props.lastUpdated) {
|
||||
this.updateEntryStyle(true)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
|
||||
handleInputChange = (event) => {
|
||||
const target = event.target
|
||||
const name = target.name
|
||||
|
||||
this.props.onEntryChange({
|
||||
id: this.props.id,
|
||||
name,
|
||||
value: target.value,
|
||||
})
|
||||
}
|
||||
|
||||
copyToClipboard = (value) => {
|
||||
const textField = document.createElement('textarea')
|
||||
textField.innerText = value
|
||||
document.body.appendChild(textField)
|
||||
textField.select()
|
||||
document.execCommand('copy')
|
||||
textField.remove()
|
||||
this.props.onCopyValue()
|
||||
}
|
||||
|
||||
updateEntryStyle = (useDelay = false) => {
|
||||
/**
|
||||
* A short amount of time to wait in order to prevent reading
|
||||
* stale information from the DOM after a theme is activated.
|
||||
*/
|
||||
const DELAY_BEFORE_READING_PROPERTIES = useDelay ? 0 : 50
|
||||
|
||||
setTimeout(() => {
|
||||
const { entryStyle } = this.state
|
||||
const entryColor = getEntryColor(document, this.props.entry)
|
||||
|
||||
if (entryColor) {
|
||||
// The background color for the entry.
|
||||
entryStyle.backgroundColor = entryColor
|
||||
|
||||
const rgbColor = hexColorToRGB(entryColor)
|
||||
const varColor = getVarColorForContrast(rgbColor)
|
||||
|
||||
// The foreground color for the entry.
|
||||
entryStyle.color = `var(${varColor})`
|
||||
}
|
||||
|
||||
this.setState({
|
||||
entryStyle,
|
||||
})
|
||||
}, DELAY_BEFORE_READING_PROPERTIES)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { service, account, notes, password, secret } = this.props.entry
|
||||
const { id, onEdit, onRemove, canEdit, style, innerRef, ...divProps } = this.props
|
||||
const { token, timeLeft, entryStyle } = this.state
|
||||
|
||||
delete divProps.onCopyValue
|
||||
delete divProps.lastUpdated
|
||||
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className="sk-notification sk-base-custom"
|
||||
style={{
|
||||
...entryStyle,
|
||||
...style,
|
||||
}}
|
||||
ref={innerRef}
|
||||
>
|
||||
<div className="auth-entry">
|
||||
{canEdit && (
|
||||
<div className="auth-drag-indicator-container">
|
||||
<DragIndicator className="grab-cursor" />
|
||||
</div>
|
||||
)}
|
||||
<div className="auth-details">
|
||||
<div className="auth-info">
|
||||
<div className="auth-service">{service}</div>
|
||||
<div className="auth-account">{account}</div>
|
||||
<div className="auth-optional">
|
||||
{notes && (
|
||||
<div className="auth-notes-row">
|
||||
<div className="auth-notes">{notes}</div>
|
||||
</div>
|
||||
)}
|
||||
{password && (
|
||||
<div className="auth-password-row">
|
||||
<div className="auth-password" onClick={() => this.copyToClipboard(password)}>
|
||||
{'•'.repeat(password.length)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{secret && (
|
||||
<div className="auth-token-info">
|
||||
<div className="auth-token" onClick={() => this.copyToClipboard(token)}>
|
||||
<div>{token.slice(0, 3)}</div>
|
||||
<div>{token.slice(3, 6)}</div>
|
||||
</div>
|
||||
<div className="auth-countdown">
|
||||
<CountdownPie
|
||||
token={token}
|
||||
timeLeft={timeLeft}
|
||||
total={30}
|
||||
bgColor={entryStyle.backgroundColor}
|
||||
fgColor={entryStyle.color}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="auth-options">
|
||||
<AuthMenu
|
||||
onEdit={onEdit.bind(this, id)}
|
||||
onRemove={onRemove.bind(this, id)}
|
||||
buttonColor={entryStyle.color}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AuthEntry.propTypes = {
|
||||
id: PropTypes.any.isRequired,
|
||||
entry: PropTypes.object.isRequired,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onRemove: PropTypes.func.isRequired,
|
||||
onEntryChange: PropTypes.func,
|
||||
onCopyValue: PropTypes.func.isRequired,
|
||||
canEdit: PropTypes.bool.isRequired,
|
||||
innerRef: PropTypes.func.isRequired,
|
||||
lastUpdated: PropTypes.number.isRequired,
|
||||
style: PropTypes.object.isRequired,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
|
||||
export default class AuthMenu extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
show: false,
|
||||
}
|
||||
}
|
||||
|
||||
onToggle = () => {
|
||||
this.setState({
|
||||
show: !this.state.show,
|
||||
})
|
||||
}
|
||||
|
||||
onEdit = () => {
|
||||
this.onToggle()
|
||||
this.props.onEdit()
|
||||
}
|
||||
|
||||
onRemove = () => {
|
||||
this.onToggle()
|
||||
this.props.onRemove()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { buttonColor } = this.props
|
||||
|
||||
const buttonStyle = {}
|
||||
if (buttonColor) {
|
||||
buttonStyle.color = buttonColor
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-menu">
|
||||
<div className="sk-button" onClick={this.onToggle} style={buttonStyle}>
|
||||
<div className="sk-label">•••</div>
|
||||
</div>
|
||||
{this.state.show &&
|
||||
((<div className="auth-overlay" onClick={this.onToggle} />),
|
||||
(
|
||||
<div className="sk-menu-panel">
|
||||
<div className="sk-menu-panel-row" onClick={this.onEdit}>
|
||||
<div className="sk-label">Edit</div>
|
||||
</div>
|
||||
<div className="sk-menu-panel-row" onClick={this.onRemove}>
|
||||
<div className="sk-label">Remove</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AuthMenu.propTypes = {
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onRemove: PropTypes.func.isRequired,
|
||||
buttonColor: PropTypes.string,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const ConfirmDialog = ({ title, message, onConfirm, onCancel }) => (
|
||||
<div className="auth-overlay">
|
||||
<div className="auth-dialog sk-panel">
|
||||
<div className="sk-panel-header">
|
||||
<div className="sk-panel-header-title">{title}</div>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section sk-panel-hero">
|
||||
<div className="sk-panel-row">
|
||||
<div className="sk-h1">{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-footer">
|
||||
<div className="sk-button-group stretch">
|
||||
<div className="sk-button neutral" onClick={onCancel}>
|
||||
<div className="sk-label">Cancel</div>
|
||||
</div>
|
||||
<div className="sk-button info" onClick={onConfirm}>
|
||||
<div className="sk-label">Confirm</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
ConfirmDialog.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
onConfirm: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default ConfirmDialog
|
||||
@@ -0,0 +1,15 @@
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const CopyNotification = ({ isVisible }) => (
|
||||
<div className={`auth-copy-notification ${isVisible ? 'visible' : 'hidden'}`}>
|
||||
<div className="sk-panel">
|
||||
<div className="sk-font-small sk-bold">Copied value to clipboard.</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
CopyNotification.propTypes = {
|
||||
isVisible: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
export default CopyNotification
|
||||
@@ -0,0 +1,131 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const animationName = (token) => `countdown${token}`
|
||||
|
||||
const rotaAnimation = (token, offset) => `@keyframes rota_${animationName(token)} {
|
||||
0% {
|
||||
transform: rotate(${offset}deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}`
|
||||
|
||||
const opaAnimation = (token, offset) => `@keyframes opa_${animationName(token)} {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${offset}%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}`
|
||||
|
||||
const opaReverseAnimation = (token, offset) => `@keyframes opa_r_${animationName(token)} {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
${offset}%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}`
|
||||
|
||||
function calculateOpaOffset(timeLeft, total) {
|
||||
const percentage = calculatePercentage(timeLeft, total) * 100
|
||||
const percTo50 = 50 - percentage
|
||||
// 8 is an offset because the animation is not in sync otherwise
|
||||
return percTo50 < 0 ? 0 : Math.ceil(Math.min(percTo50 + 8, 50))
|
||||
}
|
||||
|
||||
function calculateRotaOffset(timeLeft, total) {
|
||||
return calculatePercentage(timeLeft, total) * 360
|
||||
}
|
||||
|
||||
function calculatePercentage(timeLeft, total) {
|
||||
return (total - timeLeft) / total
|
||||
}
|
||||
|
||||
function useRotateAnimation(token, timeLeft, total) {
|
||||
useEffect(
|
||||
function createRotateAnimation() {
|
||||
const style = document.createElement('style')
|
||||
document.head.appendChild(style)
|
||||
const styleSheet = style.sheet
|
||||
|
||||
const rotaKeyframes = rotaAnimation(token, calculateRotaOffset(timeLeft, total))
|
||||
const opaKeyframes = opaAnimation(token, calculateOpaOffset(timeLeft, total))
|
||||
const opaReverseKeyframes = opaReverseAnimation(token, calculateOpaOffset(timeLeft, total))
|
||||
|
||||
styleSheet.insertRule(rotaKeyframes, styleSheet.cssRules.length)
|
||||
styleSheet.insertRule(opaKeyframes, styleSheet.cssRules.length)
|
||||
styleSheet.insertRule(opaReverseKeyframes, styleSheet.cssRules.length)
|
||||
|
||||
function cleanup() {
|
||||
style.remove()
|
||||
}
|
||||
|
||||
const timer = setTimeout(cleanup, timeLeft * 1000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
cleanup()
|
||||
}
|
||||
},
|
||||
[token, timeLeft, total],
|
||||
)
|
||||
}
|
||||
|
||||
const CountdownPie = ({ token, timeLeft, total, bgColor, fgColor }) => {
|
||||
useRotateAnimation(token, timeLeft, total)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="countdown-pie"
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pie spinner"
|
||||
style={{
|
||||
animation: `rota_${animationName(token)} ${timeLeft}s linear`,
|
||||
backgroundColor: fgColor,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="pie background"
|
||||
style={{
|
||||
backgroundColor: fgColor,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="pie filler"
|
||||
style={{
|
||||
animation: `opa_r_${animationName(token)} ${timeLeft}s steps(1, end)`,
|
||||
backgroundColor: fgColor,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="mask"
|
||||
style={{
|
||||
animation: `opa_${animationName(token)} ${timeLeft}s steps(1, end)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
CountdownPie.propTypes = {
|
||||
token: PropTypes.string.isRequired,
|
||||
timeLeft: PropTypes.number.isRequired,
|
||||
total: PropTypes.number.isRequired,
|
||||
bgColor: PropTypes.string,
|
||||
fgColor: PropTypes.string,
|
||||
}
|
||||
|
||||
export default CountdownPie
|
||||
@@ -0,0 +1,21 @@
|
||||
const DataErrorAlert = () => (
|
||||
<div className="auth-overlay">
|
||||
<div className="auth-dialog sk-panel">
|
||||
<div className="sk-panel-header">
|
||||
<div className="sk-panel-header-title">Invalid Note</div>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section sk-panel-hero">
|
||||
<div className="sk-panel-row">
|
||||
<div className="sk-h1">
|
||||
The note you selected already has existing data that is not valid with this editor. Please clear the note,
|
||||
or select a new one, and try again.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default DataErrorAlert
|
||||
@@ -0,0 +1,283 @@
|
||||
import QRCodeReader from '@Components/QRCodeReader'
|
||||
import { secretPattern } from '@Lib/otp'
|
||||
import { contextualColors, defaultBgColor, getAllContextualColors, getEntryColor } from '@Lib/utils'
|
||||
import { SKAlert } from '@standardnotes/styles'
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import { TwitterPicker } from 'react-color'
|
||||
|
||||
export default class EditEntry extends React.Component {
|
||||
static defaultProps = {
|
||||
entry: {
|
||||
service: '',
|
||||
account: '',
|
||||
secret: '',
|
||||
notes: '',
|
||||
},
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
const { id, entry } = props
|
||||
|
||||
this.state = {
|
||||
id: id,
|
||||
entry,
|
||||
showColorPicker: false,
|
||||
qrCodeError: false,
|
||||
is2fa: id !== undefined ? !!entry.secret : true,
|
||||
}
|
||||
}
|
||||
|
||||
formatSecret(secret) {
|
||||
return secret.replace(/\s/g, '').toUpperCase()
|
||||
}
|
||||
|
||||
handleInputChange = (event) => {
|
||||
const target = event.target
|
||||
const name = target.name
|
||||
|
||||
const value = name === 'secret' ? this.formatSecret(target.value) : target.value
|
||||
|
||||
this.setState((state) => ({
|
||||
entry: {
|
||||
...state.entry,
|
||||
[name]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
handleSwatchClick = () => {
|
||||
this.setState({
|
||||
showColorPicker: !this.state.showColorPicker,
|
||||
})
|
||||
}
|
||||
|
||||
handleColorPickerClose = () => {
|
||||
this.setState({
|
||||
showColorPicker: false,
|
||||
})
|
||||
}
|
||||
|
||||
removeColor = () => {
|
||||
this.setState((state) => {
|
||||
delete state.entry.color
|
||||
return {
|
||||
entry: state.entry,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onSave = () => {
|
||||
const { id, entry, is2fa } = this.state
|
||||
this.props.onSave({
|
||||
id,
|
||||
entry: {
|
||||
...entry,
|
||||
secret: is2fa ? entry.secret : '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onQRCodeSuccess = (otpData) => {
|
||||
const { issuer: labelIssuer, account } = otpData.label
|
||||
const { issuer: queryIssuer, secret } = otpData.query
|
||||
|
||||
this.setState({
|
||||
entry: {
|
||||
service: labelIssuer || queryIssuer || '',
|
||||
account,
|
||||
secret: this.formatSecret(secret),
|
||||
},
|
||||
is2fa: true,
|
||||
})
|
||||
}
|
||||
|
||||
onQRCodeError = (message) => {
|
||||
this.setState({
|
||||
qrCodeError: message,
|
||||
})
|
||||
}
|
||||
|
||||
dismissQRCodeError = () => {
|
||||
this.setState({
|
||||
qrCodeError: false,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { id, entry, showColorPicker, qrCodeError, is2fa } = this.state
|
||||
|
||||
const qrCodeAlert = new SKAlert({
|
||||
title: 'Error',
|
||||
text: qrCodeError,
|
||||
buttons: [
|
||||
{
|
||||
text: 'OK',
|
||||
style: 'info',
|
||||
action: this.dismissQRCodeError,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (qrCodeError) {
|
||||
qrCodeAlert.present()
|
||||
}
|
||||
|
||||
const entryColor = getEntryColor(document, entry)
|
||||
const swatchStyle = {
|
||||
width: '36px',
|
||||
height: '14px',
|
||||
borderRadius: '2px',
|
||||
background: `${entryColor ?? defaultBgColor}`,
|
||||
}
|
||||
|
||||
const themeColors = getAllContextualColors(document)
|
||||
const defaultColorOptions = [...themeColors, '#658bdb', '#4CBBFC', '#FF794D', '#EF5276', '#91B73D', '#9B7ECF']
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
let selectedColor = color.hex.toUpperCase()
|
||||
const colorIndex = defaultColorOptions.indexOf(selectedColor)
|
||||
|
||||
if (colorIndex > -1 && colorIndex <= themeColors.length - 1) {
|
||||
selectedColor = contextualColors[colorIndex]
|
||||
}
|
||||
|
||||
this.setState((state) => ({
|
||||
entry: {
|
||||
...state.entry,
|
||||
color: selectedColor,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const handleTypeChange = ({ target }) => {
|
||||
this.setState({
|
||||
is2fa: target.value === '2fa',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-edit sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-section-title sk-panel-row">
|
||||
<div className="sk-panel-row">
|
||||
<div className="left-header">
|
||||
<div className="sk-panel-section-title pr-4">{id != null ? 'Edit entry' : 'Add new entry'}</div>
|
||||
<div className="sk-input-group" onChange={handleTypeChange}>
|
||||
<label>
|
||||
<input className="sk-input" type="radio" value="2fa" name="type" defaultChecked={is2fa} /> 2FA
|
||||
</label>
|
||||
<label>
|
||||
<input className="sk-input" type="radio" value="password" name="type" defaultChecked={!is2fa} />{' '}
|
||||
Password only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-section-title sk-panel-row">
|
||||
{id == null && <QRCodeReader onSuccess={this.onQRCodeSuccess} onError={this.onQRCodeError} />}
|
||||
<>
|
||||
{entryColor && (
|
||||
<div className="sk-button danger" onClick={this.removeColor}>
|
||||
<div className="sk-label">Clear color</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="color-picker-swatch" onClick={this.handleSwatchClick}>
|
||||
<div style={swatchStyle} />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={this.onSave} autoComplete="off">
|
||||
<div className="sk-panel-section">
|
||||
<input
|
||||
name="service"
|
||||
className="sk-input contrast"
|
||||
placeholder="Service"
|
||||
value={entry.service}
|
||||
onChange={this.handleInputChange}
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="account"
|
||||
className="sk-input contrast"
|
||||
placeholder="Account"
|
||||
value={entry.account}
|
||||
onChange={this.handleInputChange}
|
||||
type="text"
|
||||
/>
|
||||
{is2fa && (
|
||||
<input
|
||||
name="secret"
|
||||
className="sk-input contrast"
|
||||
placeholder="Secret"
|
||||
value={entry.secret}
|
||||
onChange={this.handleInputChange}
|
||||
type="text"
|
||||
pattern={secretPattern}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
name="password"
|
||||
className="sk-input contrast"
|
||||
placeholder={`Password ${is2fa ? '(optional)' : ''}`}
|
||||
value={entry.password}
|
||||
onChange={this.handleInputChange}
|
||||
type="text"
|
||||
required={!is2fa}
|
||||
/>
|
||||
<input
|
||||
name="notes"
|
||||
className="sk-input contrast"
|
||||
placeholder="Notes"
|
||||
value={entry.notes}
|
||||
onChange={this.handleInputChange}
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
{showColorPicker && (
|
||||
<div className="color-picker-popover">
|
||||
<div className="color-picker-cover" onClick={this.handleColorPickerClose} />
|
||||
<TwitterPicker
|
||||
color={entryColor}
|
||||
colors={defaultColorOptions}
|
||||
onChangeComplete={handleColorChange}
|
||||
triangle="top-right"
|
||||
onSwatchHover={(color, event) => {
|
||||
const hoveredColor = color.hex.toUpperCase()
|
||||
if (themeColors.includes(hoveredColor)) {
|
||||
event.target.setAttribute('title', 'This color will change depending on your active theme.')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-button-group stretch">
|
||||
<button type="button" className="sk-button neutral" onClick={this.props.onCancel}>
|
||||
<div className="sk-label">Cancel</div>
|
||||
</button>
|
||||
<button type="submit" className="sk-button info">
|
||||
<div className="sk-label">{id != null ? 'Save' : 'Create'}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
EditEntry.propTypes = {
|
||||
id: PropTypes.number,
|
||||
entry: PropTypes.object.isRequired,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import ConfirmDialog from '@Components/ConfirmDialog'
|
||||
import DataErrorAlert from '@Components/DataErrorAlert'
|
||||
import EditEntry from '@Components/EditEntry'
|
||||
import ViewEntries from '@Components/ViewEntries'
|
||||
import EditorKit from '@standardnotes/editor-kit'
|
||||
import update from 'immutability-helper'
|
||||
import React from 'react'
|
||||
import ReorderIcon from '../assets/svg/reorder-icon.svg'
|
||||
import CopyNotification from './CopyNotification'
|
||||
|
||||
const initialState = {
|
||||
text: '',
|
||||
entries: [],
|
||||
parseError: false,
|
||||
editMode: false,
|
||||
editEntry: null,
|
||||
confirmRemove: false,
|
||||
confirmReorder: false,
|
||||
displayCopy: false,
|
||||
canEdit: true,
|
||||
searchValue: '',
|
||||
lastUpdated: 0,
|
||||
}
|
||||
|
||||
export default class Home extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.configureEditorKit()
|
||||
this.state = initialState
|
||||
}
|
||||
|
||||
configureEditorKit() {
|
||||
const delegate = {
|
||||
setEditorRawText: (text) => {
|
||||
let parseError = false
|
||||
let entries = []
|
||||
|
||||
if (text) {
|
||||
try {
|
||||
entries = this.parseNote(text)
|
||||
} catch (e) {
|
||||
// Couldn't parse the content
|
||||
parseError = true
|
||||
this.setState({
|
||||
parseError: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
...initialState,
|
||||
text,
|
||||
parseError,
|
||||
entries,
|
||||
})
|
||||
},
|
||||
generateCustomPreview: (text) => {
|
||||
let entries = []
|
||||
try {
|
||||
entries = this.parseNote(text)
|
||||
} finally {
|
||||
// eslint-disable-next-line no-unsafe-finally
|
||||
return {
|
||||
html: `<div><strong>${entries.length}</strong> TokenVault Entries </div>`,
|
||||
plain: `${entries.length} TokenVault Entries`,
|
||||
}
|
||||
}
|
||||
},
|
||||
clearUndoHistory: () => {},
|
||||
getElementsBySelector: () => [],
|
||||
onNoteLockToggle: (isLocked) => {
|
||||
this.setState({
|
||||
canEdit: !isLocked,
|
||||
})
|
||||
},
|
||||
onThemesChange: () => {
|
||||
this.setState({
|
||||
lastUpdated: Date.now(),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
this.editorKit = new EditorKit(delegate, {
|
||||
mode: 'json',
|
||||
supportsFileSafe: false,
|
||||
})
|
||||
}
|
||||
|
||||
parseNote(text) {
|
||||
const entries = JSON.parse(text)
|
||||
|
||||
if (entries instanceof Array) {
|
||||
if (entries.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!('service' in entry)) {
|
||||
throw Error('Service key is missing for an entry.')
|
||||
}
|
||||
|
||||
if (!('secret' in entry) && !('password' in entry)) {
|
||||
throw Error('An entry does not have a secret key or a password.')
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
saveNote(entries) {
|
||||
this.editorKit.onEditorValueChanged(JSON.stringify(entries, null, 2))
|
||||
}
|
||||
|
||||
// Entry operations
|
||||
addEntry = (entry) => {
|
||||
this.setState((state) => {
|
||||
const entries = state.entries.concat([entry])
|
||||
this.saveNote(entries)
|
||||
|
||||
return {
|
||||
editMode: false,
|
||||
editEntry: null,
|
||||
entries,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
editEntry = ({ id, entry }) => {
|
||||
this.setState((state) => {
|
||||
const entries = update(state.entries, { [id]: { $set: entry } })
|
||||
this.saveNote(entries)
|
||||
|
||||
return {
|
||||
editMode: false,
|
||||
editEntry: null,
|
||||
entries,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
removeEntry = (id) => {
|
||||
this.setState((state) => {
|
||||
const entries = update(state.entries, { $splice: [[id, 1]] })
|
||||
this.saveNote(entries)
|
||||
|
||||
return {
|
||||
confirmRemove: false,
|
||||
editEntry: null,
|
||||
entries,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Event Handlers
|
||||
onAddNew = () => {
|
||||
if (!this.state.canEdit) {
|
||||
return
|
||||
}
|
||||
this.setState({
|
||||
editMode: true,
|
||||
editEntry: null,
|
||||
})
|
||||
}
|
||||
|
||||
onEdit = (id) => {
|
||||
if (!this.state.canEdit) {
|
||||
return
|
||||
}
|
||||
this.setState((state) => ({
|
||||
editMode: true,
|
||||
editEntry: {
|
||||
id,
|
||||
entry: state.entries[id],
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
onCancel = () => {
|
||||
this.setState({
|
||||
confirmRemove: false,
|
||||
confirmReorder: false,
|
||||
editMode: false,
|
||||
editEntry: null,
|
||||
})
|
||||
}
|
||||
|
||||
onRemove = (id) => {
|
||||
if (!this.state.canEdit) {
|
||||
return
|
||||
}
|
||||
this.setState((state) => ({
|
||||
confirmRemove: true,
|
||||
editEntry: {
|
||||
id,
|
||||
entry: state.entries[id],
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
onSave = ({ id, entry }) => {
|
||||
// If there's no ID it's a new note
|
||||
if (id != null) {
|
||||
this.editEntry({ id, entry })
|
||||
} else {
|
||||
this.addEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
onCopyValue = () => {
|
||||
this.setState({
|
||||
displayCopy: true,
|
||||
})
|
||||
|
||||
if (this.clearTooltipTimer) {
|
||||
clearTimeout(this.clearTooltipTimer)
|
||||
}
|
||||
|
||||
this.clearTooltipTimer = setTimeout(() => {
|
||||
this.setState({
|
||||
displayCopy: false,
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
updateEntries = (entries) => {
|
||||
this.saveNote(entries)
|
||||
this.setState({
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
onReorderEntries = () => {
|
||||
if (!this.state.canEdit) {
|
||||
return
|
||||
}
|
||||
this.setState({
|
||||
confirmReorder: true,
|
||||
})
|
||||
}
|
||||
|
||||
onSearchChange = (event) => {
|
||||
const target = event.target
|
||||
this.setState({
|
||||
searchValue: target.value.toLowerCase(),
|
||||
})
|
||||
}
|
||||
|
||||
clearSearchValue = () => {
|
||||
this.setState({
|
||||
searchValue: '',
|
||||
})
|
||||
}
|
||||
|
||||
reorderEntries = () => {
|
||||
const { entries } = this.state
|
||||
const orderedEntries = entries.sort((a, b) => {
|
||||
const serviceA = a.service.toLowerCase()
|
||||
const serviceB = b.service.toLowerCase()
|
||||
return serviceA < serviceB ? -1 : serviceA > serviceB ? 1 : 0
|
||||
})
|
||||
this.saveNote(orderedEntries)
|
||||
this.setState({
|
||||
entries: orderedEntries,
|
||||
confirmReorder: false,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const editEntry = this.state.editEntry || {}
|
||||
const {
|
||||
canEdit,
|
||||
displayCopy,
|
||||
parseError,
|
||||
editMode,
|
||||
entries,
|
||||
confirmRemove,
|
||||
confirmReorder,
|
||||
searchValue,
|
||||
lastUpdated,
|
||||
} = this.state
|
||||
|
||||
if (parseError) {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<DataErrorAlert />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<CopyNotification isVisible={displayCopy} />
|
||||
{!editMode && (
|
||||
<div id="header">
|
||||
<div className={`sk-horizontal-group left align-items-center ${!canEdit && 'full-width'}`}>
|
||||
<input
|
||||
name="search"
|
||||
className="sk-input contrast search-bar"
|
||||
placeholder="Search entries..."
|
||||
value={searchValue}
|
||||
onChange={this.onSearchChange}
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
/>
|
||||
{searchValue && (
|
||||
<div onClick={this.clearSearchValue} className="sk-button danger">
|
||||
<div className="sk-label">✕</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="sk-horizontal-group right">
|
||||
<div className="sk-button-group stretch">
|
||||
<div onClick={this.onReorderEntries} className="sk-button info">
|
||||
<ReorderIcon />
|
||||
</div>
|
||||
<div onClick={this.onAddNew} className="sk-button info">
|
||||
<div className="sk-label">Add new</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div id="content">
|
||||
{editMode ? (
|
||||
<EditEntry id={editEntry.id} entry={editEntry.entry} onSave={this.onSave} onCancel={this.onCancel} />
|
||||
) : (
|
||||
<ViewEntries
|
||||
entries={entries}
|
||||
searchValue={searchValue}
|
||||
onEdit={this.onEdit}
|
||||
onRemove={this.onRemove}
|
||||
onCopyValue={this.onCopyValue}
|
||||
canEdit={canEdit}
|
||||
lastUpdated={lastUpdated}
|
||||
updateEntries={this.updateEntries}
|
||||
/>
|
||||
)}
|
||||
{confirmRemove && (
|
||||
<ConfirmDialog
|
||||
title={`Remove ${editEntry.entry.service}`}
|
||||
message="Are you sure you want to remove this entry?"
|
||||
onConfirm={() => this.removeEntry(editEntry.id)}
|
||||
onCancel={this.onCancel}
|
||||
/>
|
||||
)}
|
||||
{confirmReorder && (
|
||||
<ConfirmDialog
|
||||
title={'Auto-sort entries'}
|
||||
message="Are you sure you want to auto-sort all entries alphabetically based on service name?"
|
||||
onConfirm={this.reorderEntries}
|
||||
onCancel={this.onCancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { parseKeyUri } from '@Lib/otp'
|
||||
import jsQR from 'jsqr'
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
|
||||
const convertToGrayScale = (imageData) => {
|
||||
if (!imageData) {
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
const count = imageData.data[i] + imageData.data[i + 1] + imageData.data[i + 2]
|
||||
let color = 0
|
||||
|
||||
if (count > 510) {
|
||||
color = 255
|
||||
} else if (count > 255) {
|
||||
color = 127.5
|
||||
}
|
||||
|
||||
imageData.data[i] = color
|
||||
imageData.data[i + 1] = color
|
||||
imageData.data[i + 2] = color
|
||||
imageData.data[i + 3] = 255
|
||||
}
|
||||
|
||||
return imageData
|
||||
}
|
||||
|
||||
export default class QRCodeReader extends React.Component {
|
||||
onImageSelected = (evt) => {
|
||||
const file = evt.target.files[0]
|
||||
const url = URL.createObjectURL(file)
|
||||
const img = new Image()
|
||||
const self = this
|
||||
|
||||
img.onload = function () {
|
||||
URL.revokeObjectURL(this.src)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
canvas.width = this.width
|
||||
canvas.height = this.height
|
||||
context.drawImage(this, 0, 0)
|
||||
|
||||
let imageData = context.getImageData(0, 0, this.width, this.height)
|
||||
imageData = convertToGrayScale(imageData)
|
||||
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height)
|
||||
|
||||
const { onError, onSuccess } = self.props
|
||||
|
||||
if (code) {
|
||||
const otpData = parseKeyUri(code.data)
|
||||
if (otpData.type !== 'totp') {
|
||||
onError(`The '${otpData.type}' type is not supported.`)
|
||||
} else {
|
||||
onSuccess(otpData)
|
||||
}
|
||||
} else {
|
||||
onError('Error reading QR code from image. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
img.src = url
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="qr-code-reader-container">
|
||||
<div className="sk-button info">
|
||||
<div className="sk-label">Upload QR Code</div>
|
||||
<input type="file" style={{ display: 'none' }} onChange={this.onImageSelected} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
QRCodeReader.propTypes = {
|
||||
onError: PropTypes.func.isRequired,
|
||||
onSuccess: PropTypes.func.isRequired,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import AuthEntry from '@Components/AuthEntry'
|
||||
import PropTypes from 'prop-types'
|
||||
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'
|
||||
|
||||
const reorderEntries = (list, startIndex, endIndex) => {
|
||||
const result = Array.from(list)
|
||||
const [removed] = result.splice(startIndex, 1)
|
||||
result.splice(endIndex, 0, removed)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const ViewEntries = ({ entries, onEdit, onRemove, onCopyValue, canEdit, updateEntries, searchValue, lastUpdated }) => {
|
||||
const onDragEnd = (result) => {
|
||||
const droppedOutsideList = !result.destination
|
||||
if (droppedOutsideList) {
|
||||
return
|
||||
}
|
||||
|
||||
const orderedEntries = reorderEntries(entries, result.source.index, result.destination.index)
|
||||
|
||||
updateEntries(orderedEntries)
|
||||
}
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="droppable" isDropDisabled={!canEdit}>
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} className="auth-list">
|
||||
{entries.map((entry, index) => {
|
||||
/**
|
||||
* Filtering entries by account, service and notes properties.
|
||||
*/
|
||||
const combinedString = `${entry.account}${entry.service}${entry.notes}`.toLowerCase()
|
||||
if (searchValue && !combinedString.includes(searchValue)) {
|
||||
return
|
||||
}
|
||||
return (
|
||||
<Draggable
|
||||
key={`${entry.service}-${index}`}
|
||||
draggableId={`${entry.service}-${index}`}
|
||||
index={index}
|
||||
isDragDisabled={!canEdit}
|
||||
>
|
||||
{(provided) => (
|
||||
<AuthEntry
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
innerRef={provided.innerRef}
|
||||
key={index}
|
||||
id={index}
|
||||
entry={entry}
|
||||
onEdit={onEdit}
|
||||
onRemove={onRemove}
|
||||
onCopyValue={onCopyValue}
|
||||
canEdit={canEdit}
|
||||
lastUpdated={lastUpdated}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)
|
||||
}
|
||||
|
||||
ViewEntries.propTypes = {
|
||||
entries: PropTypes.arrayOf(PropTypes.object),
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onRemove: PropTypes.func.isRequired,
|
||||
onCopyValue: PropTypes.func.isRequired,
|
||||
canEdit: PropTypes.bool.isRequired,
|
||||
lastUpdated: PropTypes.number.isRequired,
|
||||
updateEntries: PropTypes.func.isRequired,
|
||||
searchValue: PropTypes.string,
|
||||
}
|
||||
|
||||
export default ViewEntries
|
||||
@@ -0,0 +1,4 @@
|
||||
import Home from '@Components/Home'
|
||||
import ReactDOM from 'react-dom'
|
||||
|
||||
ReactDOM.render(<Home />, document.body.appendChild(document.createElement('div')))
|
||||
@@ -0,0 +1,184 @@
|
||||
import { base32ToHex, bufToHex, decToHex, hextoBuf, hexToBytes, leftpad } from '@Lib/utils'
|
||||
export { parseKeyUri, secretPattern } from '@Lib/utils'
|
||||
|
||||
class Hotp {
|
||||
/**
|
||||
* Generate a counter based One Time Password
|
||||
*
|
||||
* @return {String} the one time password
|
||||
*
|
||||
* Arguments:
|
||||
*
|
||||
* args
|
||||
* key - Key for the one time password. This should be unique and secret for
|
||||
* every user as this is the seed that is used to calculate the HMAC
|
||||
*
|
||||
* counter - Counter value. This should be stored by the application, must
|
||||
* be user specific, and be incremented for each request.
|
||||
*
|
||||
*/
|
||||
async gen(secret, opt) {
|
||||
var key = base32ToHex(secret) || ''
|
||||
opt = opt || {}
|
||||
var counter = opt.counter || 0
|
||||
|
||||
var hexCounter = leftpad(decToHex(counter), 16, '0')
|
||||
var digest = await this.createHmac('SHA-1', key, hexCounter)
|
||||
var h = hexToBytes(digest)
|
||||
|
||||
// Truncate
|
||||
var offset = h[h.length - 1] & 0xf
|
||||
var v =
|
||||
((h[offset] & 0x7f) << 24) |
|
||||
((h[offset + 1] & 0xff) << 16) |
|
||||
((h[offset + 2] & 0xff) << 8) |
|
||||
(h[offset + 3] & 0xff)
|
||||
|
||||
v = (v % 1000000) + ''
|
||||
|
||||
return Array(7 - v.length).join('0') + v
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a One Time Password based on a counter.
|
||||
*
|
||||
* @return {Object} null if failure, { delta: # } on success
|
||||
* delta is the time step difference between the client and the server
|
||||
*
|
||||
* Arguments:
|
||||
*
|
||||
* args
|
||||
* key - Key for the one time password. This should be unique and secret for
|
||||
* every user as it is the seed used to calculate the HMAC
|
||||
*
|
||||
* token - Passcode to validate.
|
||||
*
|
||||
* window - The allowable margin for the counter. The function will check
|
||||
* 'W' codes in the future against the provided passcode. Note,
|
||||
* it is the calling applications responsibility to keep track of
|
||||
* 'W' and increment it for each password check, and also to adjust
|
||||
* it accordingly in the case where the client and server become
|
||||
* out of sync (second argument returns non zero).
|
||||
* E.g. if W = 100, and C = 5, this function will check the passcode
|
||||
* against all One Time Passcodes between 5 and 105.
|
||||
*
|
||||
* Default - 50
|
||||
*
|
||||
* counter - Counter value. This should be stored by the application, must
|
||||
* be user specific, and be incremented for each request.
|
||||
*
|
||||
*/
|
||||
async verify(token, key, opt) {
|
||||
opt = opt || {}
|
||||
var window = opt.window || 50
|
||||
var counter = opt.counter || 0
|
||||
|
||||
// Now loop through from C to C + W to determine if there is
|
||||
// a correct code
|
||||
for (var i = counter - window; i <= counter + window; ++i) {
|
||||
opt.counter = i
|
||||
if ((await this.gen(key, opt)) === token) {
|
||||
// We have found a matching code, trigger callback
|
||||
// and pass offset
|
||||
return { delta: i - counter }
|
||||
}
|
||||
}
|
||||
|
||||
// If we get to here then no codes have matched, return null
|
||||
return null
|
||||
}
|
||||
|
||||
async createHmac(alg, key, str) {
|
||||
const hmacKey = await window.crypto.subtle.importKey(
|
||||
'raw', // raw format of the key - should be Uint8Array
|
||||
hextoBuf(key),
|
||||
{
|
||||
// algorithm details
|
||||
name: 'HMAC',
|
||||
hash: { name: alg },
|
||||
},
|
||||
false, // export = false
|
||||
['sign'], // what this key can do
|
||||
)
|
||||
const sig = await window.crypto.subtle.sign('HMAC', hmacKey, hextoBuf(str))
|
||||
return bufToHex(sig)
|
||||
}
|
||||
}
|
||||
|
||||
export const hotp = new Hotp()
|
||||
|
||||
class Totp {
|
||||
/**
|
||||
* Generate a time based One Time Password
|
||||
*
|
||||
* @return {String} the one time password
|
||||
*
|
||||
* Arguments:
|
||||
*
|
||||
* args
|
||||
* key - Key for the one time password. This should be unique and secret for
|
||||
* every user as it is the seed used to calculate the HMAC
|
||||
*
|
||||
* time - The time step of the counter. This must be the same for
|
||||
* every request and is used to calculat C.
|
||||
*
|
||||
* Default - 30
|
||||
*
|
||||
*/
|
||||
async gen(key, opt) {
|
||||
opt = opt || {}
|
||||
var time = opt.time || 30
|
||||
var _t = Date.now()
|
||||
|
||||
// Determine the value of the counter, C
|
||||
// This is the number of time steps in seconds since T0
|
||||
opt.counter = Math.floor(_t / 1000 / time)
|
||||
|
||||
return hotp.gen(key, opt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a One Time Password based on a timer.
|
||||
*
|
||||
* @return {Object} null if failure, { delta: # } on success
|
||||
* delta is the time step difference between the client and the server
|
||||
*
|
||||
* Arguments:
|
||||
*
|
||||
* args
|
||||
* key - Key for the one time password. This should be unique and secret for
|
||||
* every user as it is the seed used to calculate the HMAC
|
||||
*
|
||||
* token - Passcode to validate.
|
||||
*
|
||||
* window - The allowable margin for the counter. The function will check
|
||||
* 'W' codes either side of the provided counter. Note,
|
||||
* it is the calling applications responsibility to keep track of
|
||||
* 'W' and increment it for each password check, and also to adjust
|
||||
* it accordingly in the case where the client and server become
|
||||
* out of sync (second argument returns non zero).
|
||||
* E.g. if W = 5, and C = 1000, this function will check the passcode
|
||||
* against all One Time Passcodes between 995 and 1005.
|
||||
*
|
||||
* Default - 6
|
||||
*
|
||||
* time - The time step of the counter. This must be the same for
|
||||
* every request and is used to calculate C.
|
||||
*
|
||||
* Default - 30
|
||||
*
|
||||
*/
|
||||
async verify(token, key, opt) {
|
||||
opt = opt || {}
|
||||
var time = opt.time || 30
|
||||
var _t = Date.now()
|
||||
|
||||
// Determine the value of the counter, C
|
||||
// This is the number of time steps in seconds since T0
|
||||
opt.counter = Math.floor(_t / 1000 / time)
|
||||
|
||||
return hotp.verify(token, key, opt)
|
||||
}
|
||||
}
|
||||
|
||||
export const totp = new Totp()
|
||||
@@ -0,0 +1,185 @@
|
||||
const base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
export const secretPattern = `^[${base32chars}]{16,}$`
|
||||
|
||||
export function hexToBytes(hex) {
|
||||
var bytes = []
|
||||
for (var c = 0, C = hex.length; c < C; c += 2) {
|
||||
bytes.push(parseInt(hex.substr(c, 2), 16))
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export function decToHex(s) {
|
||||
return (s < 15.5 ? '0' : '') + Math.round(s).toString(16)
|
||||
}
|
||||
|
||||
export function bufToHex(buf) {
|
||||
return Array.prototype.map.call(new Uint8Array(buf), (x) => ('00' + x.toString(16)).slice(-2)).join('')
|
||||
}
|
||||
|
||||
export function hextoBuf(hex) {
|
||||
var view = new Uint8Array(hex.length / 2)
|
||||
|
||||
for (var i = 0; i < hex.length; i += 2) {
|
||||
view[i / 2] = parseInt(hex.substring(i, i + 2), 16)
|
||||
}
|
||||
|
||||
return view.buffer
|
||||
}
|
||||
|
||||
export function base32ToHex(base32) {
|
||||
var bits, chunk, hex, i, val
|
||||
bits = ''
|
||||
hex = ''
|
||||
i = 0
|
||||
while (i < base32.length) {
|
||||
val = base32chars.indexOf(base32.charAt(i).toUpperCase())
|
||||
bits += leftpad(val.toString(2), 5, '0')
|
||||
i++
|
||||
}
|
||||
i = 0
|
||||
while (i + 4 <= bits.length) {
|
||||
chunk = bits.substr(i, 4)
|
||||
hex = hex + parseInt(chunk, 2).toString(16)
|
||||
i += 4
|
||||
}
|
||||
return hex
|
||||
}
|
||||
|
||||
export function leftpad(str, len, pad) {
|
||||
if (len + 1 >= str.length) {
|
||||
str = Array(len + 1 - str.length).join(pad) + str
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* This function takes an otpauth:// style key URI and parses it into an object with keys for the
|
||||
* various parts of the URI
|
||||
*
|
||||
* @param {String} uri The otpauth:// uri that you want to parse
|
||||
*
|
||||
* @return {Object} The parsed URI or null on failure. The URI object looks like this:
|
||||
*
|
||||
* {
|
||||
* type: 'totp',
|
||||
* label: { issuer: 'ACME Co', account: 'jane@example.com' },
|
||||
* query: {
|
||||
* secret: 'JBSWY3DPEHPK3PXP',
|
||||
* digits: '6'
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @see <a href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format">otpauth Key URI Format</a>
|
||||
*/
|
||||
export function parseKeyUri(uri) {
|
||||
// Quick sanity check
|
||||
if (typeof uri !== 'string' || uri.length < 7) return null
|
||||
|
||||
// I would like to just use new URL(), but the behavior is different between node and browsers, so
|
||||
// we have to do some of the work manually with regex.
|
||||
const parts = /otpauth:\/\/([A-Za-z]+)\/([^?]+)\??(.*)?/i.exec(uri)
|
||||
|
||||
if (!parts || parts.length < 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const [fullUri, type, fullLabel] = parts
|
||||
|
||||
// Sanity check type and label
|
||||
if (!type || !fullLabel) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse the label
|
||||
const decodedLabel = decodeURIComponent(fullLabel)
|
||||
|
||||
const labelParts = decodedLabel.split(/: ?/)
|
||||
|
||||
const label =
|
||||
labelParts && labelParts.length === 2
|
||||
? { issuer: labelParts[0], account: labelParts[1] }
|
||||
: { issuer: '', account: decodedLabel }
|
||||
|
||||
// Parse query string
|
||||
const qs = parts[3] ? new URLSearchParams(parts[3]) : []
|
||||
|
||||
const query = [...qs].reduce((acc, [key, value]) => {
|
||||
acc[key] = value
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// Returned the parsed parts of the URI
|
||||
return { type: type.toLowerCase(), label, query }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a hex color string to an object containing RGB values.
|
||||
*/
|
||||
export function hexColorToRGB(hexColor) {
|
||||
// Expand the shorthand form (e.g. "0AB") to full form (e.g. "00AABB")
|
||||
const shortHandFormRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
|
||||
hexColor = hexColor.replace(shortHandFormRegex, function (m, red, green, blue) {
|
||||
return red + red + green + green + blue + blue
|
||||
})
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hexColor)
|
||||
return result
|
||||
? {
|
||||
red: parseInt(result[1], 16),
|
||||
green: parseInt(result[2], 16),
|
||||
blue: parseInt(result[3], 16),
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
export const defaultBgColor = '#FFF'
|
||||
|
||||
/**
|
||||
* Gets the color variable to be used based on the calculated constrast of a color.
|
||||
*/
|
||||
export function getVarColorForContrast(backgroundColor) {
|
||||
const styleKitColors = {
|
||||
foreground: '--sn-stylekit-contrast-foreground-color',
|
||||
background: '--sn-stylekit-contrast-background-color',
|
||||
}
|
||||
if (!backgroundColor) {
|
||||
return styleKitColors.foreground
|
||||
}
|
||||
const colorContrast = Math.round(
|
||||
(parseInt(backgroundColor.red) * 299 +
|
||||
parseInt(backgroundColor.green) * 587 +
|
||||
parseInt(backgroundColor.blue) * 114) /
|
||||
1000,
|
||||
)
|
||||
return colorContrast > 70 ? styleKitColors.background : styleKitColors.foreground
|
||||
}
|
||||
|
||||
function getPropertyValue(document, propertyName) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(propertyName).trim().toUpperCase()
|
||||
}
|
||||
|
||||
export const contextualColors = ['info', 'success', 'neutral', 'warning']
|
||||
|
||||
export function getContextualColor(document, colorName) {
|
||||
if (!contextualColors.includes(colorName)) {
|
||||
return
|
||||
}
|
||||
|
||||
return getPropertyValue(document, `--sn-stylekit-${colorName}-color`)
|
||||
}
|
||||
|
||||
export function getEntryColor(document, entry) {
|
||||
const { color } = entry
|
||||
|
||||
if (!contextualColors.includes(color)) {
|
||||
return color
|
||||
}
|
||||
|
||||
return getContextualColor(document, color)
|
||||
}
|
||||
|
||||
export function getAllContextualColors(document) {
|
||||
return contextualColors.map((colorName) => getContextualColor(document, colorName))
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
@import '~@standardnotes/styles/src/Styles/main.scss';
|
||||
|
||||
body,
|
||||
html {
|
||||
background-color: var(--sn-stylekit-contrast-background-color);
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
* {
|
||||
// To prevent gray flash when focusing input on mobile Safari
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
font-family: var(--sn-stylekit-sans-serif-font);
|
||||
}
|
||||
|
||||
.sn-component {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
min-height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
.sk-panel-content {
|
||||
height: inherit !important;
|
||||
}
|
||||
}
|
||||
|
||||
#header {
|
||||
border-bottom: 1px solid var(--sn-stylekit-border-color);
|
||||
background-color: var(--sn-stylekit-background-color);
|
||||
color: var(--sn-stylekit-foreground-color);
|
||||
|
||||
min-height: 26px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#content {
|
||||
background-color: var(--sn-stylekit-contrast-background-color);
|
||||
flex: 1;
|
||||
padding: 0 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.auth-dialog {
|
||||
min-width: 380px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.auth-overlay {
|
||||
position: fixed !important;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.auth-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.auth-edit {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.sk-notification {
|
||||
// Hacking sk-notification style
|
||||
overflow: visible !important; // We need this for the dropdown menu
|
||||
margin: 10px 0 0 0 !important;
|
||||
padding: 28px 14px 28px 28px !important;
|
||||
}
|
||||
|
||||
.auth-optional {
|
||||
margin-top: 15px;
|
||||
.auth-notes-row {
|
||||
.auth-notes {
|
||||
font-size: var(--sn-stylekit-base-font-size);
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
.auth-password-row {
|
||||
margin-top: 8px;
|
||||
|
||||
.auth-password {
|
||||
font-size: var(--sn-stylekit-font-size-h1);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy token notification
|
||||
.auth-copy-notification {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 20px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 200;
|
||||
|
||||
.sk-panel {
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&.visible {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: visibility 0s 500ms, opacity 500ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* entry default styles */
|
||||
.auth-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
// Collapse on mobile
|
||||
@media screen and (max-width: 480px) {
|
||||
.auth-details {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
|
||||
.auth-info {
|
||||
margin: 4px 0;
|
||||
min-width: inherit;
|
||||
word-wrap: break-word;
|
||||
|
||||
.auth-service {
|
||||
font-size: var(--sn-stylekit-font-size-h1);
|
||||
font-weight: bold;
|
||||
line-height: 1.9rem;
|
||||
}
|
||||
|
||||
.auth-account {
|
||||
line-height: 1.5rem;
|
||||
font-size: var(--sn-stylekit-font-size-p);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-token-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 12px;
|
||||
|
||||
.auth-token {
|
||||
font-size: 2rem;
|
||||
align-self: center;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 8.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-countdown {
|
||||
padding: 0 12px;
|
||||
|
||||
// Countdown animation
|
||||
#countdown {
|
||||
transform: rotateY(-180deg) rotateZ(-90deg);
|
||||
height: 1.8rem;
|
||||
width: 1.8rem;
|
||||
align-self: center;
|
||||
|
||||
circle {
|
||||
stroke-dasharray: 113px;
|
||||
stroke-dashoffset: 0px;
|
||||
stroke-width: 4px;
|
||||
stroke: var(--sn-stylekit-success-color);
|
||||
fill: none;
|
||||
animation: countdown 10s linear infinite forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-options {
|
||||
color: var(--sn-stylekit-contrast-foreground-color);
|
||||
overflow: visible;
|
||||
margin-bottom: 8px;
|
||||
align-self: center;
|
||||
|
||||
.sk-menu-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-drag-indicator-container {
|
||||
color: inherit;
|
||||
overflow: visible;
|
||||
margin-bottom: 8px;
|
||||
align-self: center;
|
||||
padding-right: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
/* spinner */
|
||||
.countdown-pie {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--sn-stylekit-background-color);
|
||||
}
|
||||
|
||||
.countdown-pie,
|
||||
.countdown-pie * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.countdown-pie .pie {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
transform-origin: 100% 50%;
|
||||
position: absolute !important;
|
||||
background: var(--sn-stylekit-info-color);
|
||||
}
|
||||
|
||||
.countdown-pie .spinner {
|
||||
border-radius: 100% 0 0 100% / 50% 0 0 50%;
|
||||
z-index: 20;
|
||||
border-right: none;
|
||||
// Injected in CountdownPie.js
|
||||
// animation: rota 30s linear infinite;
|
||||
}
|
||||
|
||||
.countdown-pie .background {
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
z-index: 40;
|
||||
color: inherit;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.countdown-pie .filler {
|
||||
border-radius: 0 100% 100% 0 / 0 50% 50% 0;
|
||||
left: 50%;
|
||||
opacity: 0;
|
||||
z-index: 10;
|
||||
// Injected in CountdownPie.js
|
||||
// animation: opa 30s steps(1, end) infinite reverse;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.countdown-pie .mask {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background: inherit;
|
||||
opacity: 1;
|
||||
z-index: 30;
|
||||
// Injected in CountdownPie.js
|
||||
// animation: opa 30s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.color-picker-swatch {
|
||||
padding: 5px;
|
||||
background: var(--sn-stylekit-contrast-background-color);
|
||||
border-radius: 1px;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-picker-popover {
|
||||
position: absolute;
|
||||
z-index: 200;
|
||||
right: 40px;
|
||||
top: 80px;
|
||||
}
|
||||
|
||||
.color-picker-cover {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
.align-items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sk-base-custom {
|
||||
color: var(--sn-stylekit-foreground-color);
|
||||
position: relative;
|
||||
background-color: var(--sn-stylekit-background-color);
|
||||
overflow: hidden;
|
||||
border-radius: var(--sn-stylekit-general-border-radius);
|
||||
border-color: var(--sn-stylekit-border-color);
|
||||
border: 1px solid var(--sn-stylekit-border-color);
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.left {
|
||||
width: 60% !important;
|
||||
}
|
||||
|
||||
.right {
|
||||
width: 40% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 600px) {
|
||||
.left {
|
||||
width: 75% !important;
|
||||
}
|
||||
|
||||
.right {
|
||||
width: 25% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
.left {
|
||||
width: 80% !important;
|
||||
}
|
||||
|
||||
.right {
|
||||
width: 20% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 992px) {
|
||||
.left {
|
||||
width: 85% !important;
|
||||
}
|
||||
|
||||
.right {
|
||||
width: 15% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.left {
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
height: 27px;
|
||||
}
|
||||
// Injected in CountdownPie.js
|
||||
// @keyframes rota {
|
||||
// 0% {
|
||||
// transform: rotate(0deg);
|
||||
// }
|
||||
|
||||
// 100% {
|
||||
// transform: rotate(360deg);
|
||||
// }
|
||||
// }
|
||||
|
||||
// Injected in CountdownPie.js
|
||||
// @keyframes opa {
|
||||
// 0% {
|
||||
// opacity: 1;
|
||||
// }
|
||||
|
||||
// 50%,
|
||||
// 100% {
|
||||
// opacity: 0;
|
||||
// }
|
||||
// }
|
||||
|
||||
.qr-code-reader-container {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
// Show palette icon on the first 4 color rectangles.
|
||||
div.twitter-picker > div:nth-child(3) > span:nth-child(-n + 4) > div {
|
||||
background-image: url('../assets/svg/palette.svg') !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: top 4px right 4px !important;
|
||||
background-size: 12px 12px !important;
|
||||
}
|
||||
|
||||
.grab-cursor {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.left-header {
|
||||
display: flex;
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sk-input-group {
|
||||
> * {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 0.73125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<base target="_blank">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"identifier": "org.standardnotes.token-vault-dev",
|
||||
"name": "TokenVault - Development",
|
||||
"content_type": "SN|Component",
|
||||
"area": "editor-editor",
|
||||
"version": "1.0.0",
|
||||
"url": "http://localhost:8001/"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@standardnotes/authenticator",
|
||||
"version": "2.1.2",
|
||||
"main": "dist/dist.js",
|
||||
"author": "Standard Notes",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"components:compile": "webpack --config webpack.prod.js",
|
||||
"start": "webpack serve --config webpack.dev.js --progress --hot",
|
||||
"skip:lint": "eslint app/ --ext .js",
|
||||
"lint:fix": "eslint app/ --ext .js --fix",
|
||||
"test": "echo \"Error: no test specified\" && exit 0",
|
||||
"format": "prettier --write 'app/**/*.{html,css,scss,js,jsx,ts,tsx,json}' README.md"
|
||||
},
|
||||
"sn": {
|
||||
"main": "dist/index.html"
|
||||
},
|
||||
"lint-staged": {
|
||||
"README.md": [
|
||||
"prettier --write"
|
||||
],
|
||||
"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.18.5",
|
||||
"@babel/eslint-parser": "^7.18.2",
|
||||
"@babel/plugin-proposal-class-properties": "^7.17.12",
|
||||
"@babel/plugin-transform-runtime": "^7.18.5",
|
||||
"@babel/preset-env": "^7.18.2",
|
||||
"@babel/preset-react": "^7.17.12",
|
||||
"@standardnotes/editor-kit": "2.2.5",
|
||||
"@standardnotes/eslint-config-extensions": "^1.0.4",
|
||||
"@standardnotes/styles": "workspace:*",
|
||||
"@svgr/webpack": "^6.2.1",
|
||||
"babel-loader": "^8.2.5",
|
||||
"css-loader": "*",
|
||||
"eslint": "*",
|
||||
"eslint-plugin-react": "^7.30.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"jsqr": "^1.4.0",
|
||||
"mini-css-extract-plugin": "^2.6.1",
|
||||
"node-sass": "*",
|
||||
"notp": "^2.0.3",
|
||||
"otplib": "^12.0.1",
|
||||
"prettier": "*",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"sass-loader": "*",
|
||||
"style-loader": "~3.3.1",
|
||||
"svg-url-loader": "^7.1.1",
|
||||
"terser-webpack-plugin": "^5.3.3",
|
||||
"webpack": "*",
|
||||
"webpack-cli": "*",
|
||||
"webpack-dev-server": "*",
|
||||
"webpack-merge": "^5.8.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
const path = require('path')
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
|
||||
module.exports = {
|
||||
context: __dirname,
|
||||
entry: [
|
||||
path.resolve(__dirname, 'app/index.js'),
|
||||
path.resolve(__dirname, 'app/stylesheets/main.scss')
|
||||
],
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'dist.js'
|
||||
},
|
||||
externals: {
|
||||
'filesafe-js': {}
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.s[ac]ss$/i,
|
||||
exclude: /node_modules/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
"css-loader",
|
||||
{
|
||||
loader: "sass-loader"
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.js[x]?$/,
|
||||
exclude: /node_modules/,
|
||||
use: ['babel-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.svg$/i,
|
||||
issuer: /\.[jt]sx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: ['@svgr/webpack'],
|
||||
},
|
||||
{
|
||||
test: /\.svg$/i,
|
||||
issuer: /\.s[ac]ss$/i,
|
||||
exclude: /node_modules/,
|
||||
use: [
|
||||
{
|
||||
loader: 'svg-url-loader',
|
||||
options: {
|
||||
limit: 10000
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
alias: {
|
||||
'@Components': path.resolve(__dirname, 'app/components'),
|
||||
'@Lib': path.resolve(__dirname, 'app/lib')
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
new MiniCssExtractPlugin({
|
||||
filename: "dist.css"
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
title: "TokenVault",
|
||||
template: 'editor.index.ejs',
|
||||
filename: 'index.html'
|
||||
})
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const path = require('path');
|
||||
const { merge } = require('webpack-merge');
|
||||
const config = require('./webpack.config.js');
|
||||
|
||||
module.exports = merge(config, {
|
||||
mode: 'development',
|
||||
devtool: 'cheap-source-map',
|
||||
devServer: {
|
||||
port: 8001,
|
||||
static: path.resolve(__dirname, 'dist'),
|
||||
allowedHosts: "all",
|
||||
historyApiFallback: true,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization'
|
||||
}
|
||||
},
|
||||
watchOptions: {
|
||||
aggregateTimeout: 300,
|
||||
poll: 1000
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
const { merge } = require('webpack-merge');
|
||||
const config = require('./webpack.config.js');
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
|
||||
module.exports = merge(config, {
|
||||
mode: 'production',
|
||||
optimization: {
|
||||
minimize: true,
|
||||
minimizer: [new TerserPlugin()]
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user