feat: deprecated editors (#1166)

This commit is contained in:
Mo
2022-06-27 14:08:33 -05:00
committed by GitHub
parent d340ff9d77
commit 60ca415044
642 changed files with 33059 additions and 9259 deletions

View File

@@ -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"
]
}

View File

@@ -0,0 +1,2 @@
> 0.25%
not dead

View File

@@ -0,0 +1,3 @@
node_modules/**
dist/**
webpack.*

View File

@@ -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"
}
}

View 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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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>
)
}
}

View File

@@ -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,
}

View File

@@ -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

View File

@@ -0,0 +1,4 @@
import Home from '@Components/Home'
import ReactDOM from 'react-dom'
ReactDOM.render(<Home />, document.body.appendChild(document.createElement('div')))

View File

@@ -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()

View File

@@ -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))
}

View File

@@ -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;
}
}
}
}

View File

@@ -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>

View File

@@ -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/"
}

View File

@@ -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"
}
}

View File

@@ -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'
})
]
}

View File

@@ -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
},
});

View File

@@ -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()]
}
});