├── .gitignore ├── .rescriptsrc.js ├── .vscode └── launch.json ├── .webpack.config.js ├── LICENSE ├── README.md ├── package.json ├── public ├── electron.js ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.css ├── App.js ├── App.test.js ├── actions │ ├── index.js │ └── types.js ├── components │ ├── AddButton.js │ ├── Header.js │ ├── KeyList.js │ ├── Tooltip.js │ └── styled.js ├── helpers │ ├── history.js │ └── index.js ├── index.css ├── index.js ├── logo.svg ├── reducers │ ├── index.js │ └── keys_reducer.js ├── screens │ ├── AddForm.js │ └── Browser.js └── serviceWorker.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /dist 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /.rescriptsrc.js: -------------------------------------------------------------------------------- 1 | module.exports = [require.resolve('./.webpack.config.js')] 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "http://localhost:3000", 12 | "webRoot": "${workspaceFolder}/src" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /.webpack.config.js: -------------------------------------------------------------------------------- 1 | // define child rescript 2 | module.exports = config => { 3 | config.target = "electron-renderer"; 4 | return config; 5 | }; 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Vipin Ajayakumar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # SSH key manager (Mac only) 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | 5 | > A Graphical User Interface (GUI) for easily managing ssh keys on your Mac. 6 | 7 | ![Demo](../assets/demo.gif) 8 | 9 | ## Installation 10 | 11 | Just download the latest 12 | release. Easy peasy! 13 | 14 | ## Features 15 | 16 | - List all your existing ssh keys 17 | - Copy public key 18 | - Delete a key 19 | - Add a new key with optional passphrase and comment 20 | 21 | ## Contributing 22 | 23 | I made this app just for the fun of it and to learn a bit about [Electron](https://electronjs.org/) and 24 | [React](https://reactjs.org/). At the moment the functionality is very basic, and there's a lot that can 25 | be done to make it better. If you want to contribute, please get in touch or submit a 26 | pull request. 27 | 28 | ## Support 29 | 30 | Reach out to me at one of the following places. 31 | 32 | - Website at `vipinajayakumar.com` 33 | - Twitter at `@vipinajayakumar` 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ssh-key-manager", 3 | "homepage": "./", 4 | "version": "1.0.0", 5 | "private": true, 6 | "dependencies": { 7 | "electron-is-dev": "1.1.0", 8 | "ls": "0.2.1", 9 | "react": "^16.8.6", 10 | "react-dom": "^16.8.6", 11 | "react-redux": "7.0.3", 12 | "react-router": "5.0.0", 13 | "react-router-dom": "5.0.0", 14 | "react-scripts": "3.0.0", 15 | "react-tooltip": "3.10.0", 16 | "redux": "4.0.1", 17 | "redux-form": "8.2.0", 18 | "redux-thunk": "2.3.0", 19 | "styled-components": "4.2.0" 20 | }, 21 | "main": "public/electron.js", 22 | "scripts": { 23 | "start": "rescripts start", 24 | "build": "rescripts build", 25 | "test": "rescripts test", 26 | "eject": "react-scripts eject", 27 | "electron-dev": "concurrently \"BROWSER=none yarn start\" \"wait-on http://localhost:3000 && electron .\"", 28 | "postinstall": "electron-builder install-app-deps", 29 | "preelectron-pack": "yarn build", 30 | "electron-pack": "build -mw" 31 | }, 32 | "eslintConfig": { 33 | "extends": "react-app" 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | }, 47 | "devDependencies": { 48 | "@rescripts/cli": "0.0.10", 49 | "@rescripts/rescript-env": "0.0.10", 50 | "concurrently": "4.1.0", 51 | "electron": "5.0.1", 52 | "electron-builder": "20.39.0", 53 | "redux-devtools-extension": "2.13.8", 54 | "typescript": "3.4.5", 55 | "wait-on": "3.2.0" 56 | }, 57 | "author": { 58 | "name": "Vipin Ajayakumar", 59 | "email": "vipinajayakumar@icloud.com", 60 | "url": "https://www.vipinajayakumar.com" 61 | }, 62 | "build": { 63 | "appId": "com.vipinajayakumar.ssh-key-manager", 64 | "productName": "ssh-key-manager", 65 | "copyright": "Copyright © 2019 ${author}", 66 | "mac": { 67 | "category": "public.app-category.utilities" 68 | }, 69 | "files": [ 70 | "build/**/*", 71 | "node_modules/**/*" 72 | ], 73 | "directories": { 74 | "buildResources": "assets" 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /public/electron.js: -------------------------------------------------------------------------------- 1 | const electron = require("electron"); 2 | const os = require("os"); 3 | 4 | const { app, BrowserWindow, ipcMain } = electron; 5 | 6 | const path = require("path"); 7 | const isDev = require("electron-is-dev"); 8 | 9 | const { exec } = require("child_process"); 10 | 11 | let mainWindow; 12 | 13 | function createWindow() { 14 | mainWindow = new BrowserWindow({ 15 | width: 900, 16 | height: 680, 17 | webPreferences: { 18 | nodeIntegration: true 19 | } 20 | }); 21 | mainWindow.loadURL( 22 | isDev 23 | ? "http://localhost:3000" 24 | : `file://${path.join(__dirname, "../build/index.html")}` 25 | ); 26 | if (isDev) { 27 | // Open the DevTools. 28 | BrowserWindow.addDevToolsExtension( 29 | path.join( 30 | os.homedir(), 31 | "/Library/Application Support/Google/Chrome/Default/Extensions/fmkadmapgofadopljbjfkapdkoienihi/3.6.0_0" 32 | ) 33 | ); 34 | BrowserWindow.addDevToolsExtension( 35 | path.join( 36 | os.homedir(), 37 | "/Library/Application Support/Google/Chrome/Default/Extensions/lmhkpmbekcpmknklioeibfkpmmfibljd/2.17.0_0" 38 | ) 39 | ); 40 | mainWindow.webContents.openDevTools(); 41 | } 42 | mainWindow.on("closed", () => (mainWindow = null)); 43 | } 44 | 45 | app.on("ready", createWindow); 46 | 47 | app.on("window-all-closed", () => { 48 | if (process.platform !== "darwin") { 49 | app.quit(); 50 | } 51 | }); 52 | 53 | app.on("activate", () => { 54 | if (mainWindow === null) { 55 | createWindow(); 56 | } 57 | }); 58 | 59 | ipcMain.on("get:keys", event => { 60 | const homedir = process.env.HOME; 61 | const sshdir = homedir + "/.ssh"; 62 | exec("ls -a " + sshdir, (err, stdout, stderr) => { 63 | if (err) { 64 | console.error(`exec error: ${err}`); 65 | return; 66 | } 67 | 68 | const filenames = stdout.split(/\r?\n/).slice(2); 69 | filenames.pop(); 70 | 71 | var keys = []; 72 | const filenamesLength = filenames.length; 73 | for (var i = 0; i < filenamesLength; i++) { 74 | const filename = filenames[i]; 75 | if (filename.endsWith(".pub")) { 76 | const publicKeyFilename = filename; 77 | const privateKeyFilename = filename.split(".")[0]; 78 | keys.push({ 79 | privateKeyFilename, 80 | publicKeyFilename, 81 | privateKeyPath: sshdir + "/" + privateKeyFilename, 82 | publicKeyPath: sshdir + "/" + publicKeyFilename 83 | }); 84 | } 85 | } 86 | 87 | mainWindow.webContents.send("received:keys", keys); 88 | }); 89 | }); 90 | 91 | ipcMain.on("remove:key", (event, key) => { 92 | const { privateKeyPath, publicKeyPath } = key; 93 | exec( 94 | "rm " + privateKeyPath + " " + publicKeyPath, 95 | (err, stdout, stderr) => { 96 | if (err) { 97 | console.error(`exec error: ${err}`); 98 | return; 99 | } 100 | 101 | mainWindow.webContents.send("removed:key", key); 102 | } 103 | ); 104 | }); 105 | 106 | ipcMain.on("add:key", (event, key) => { 107 | const homedir = process.env.HOME; 108 | const sshdir = homedir + "/.ssh"; 109 | const { filename, passphrase, comment } = key; 110 | var command = 111 | "ssh-keygen -t rsa -b 4096 -f " + 112 | sshdir + 113 | "/" + 114 | filename + 115 | " -N " + 116 | (passphrase ? passphrase + '""' : '""') + 117 | (comment ? " -C " + '"' + comment + '"' : ""); 118 | 119 | exec(command, (err, stdout, stderr) => { 120 | if (err) { 121 | console.error(`exec error: ${err}`); 122 | return; 123 | } 124 | 125 | mainWindow.webContents.send("added:key", key); 126 | }); 127 | }); 128 | 129 | ipcMain.on("copy:key", (event, key) => { 130 | const { publicKeyPath } = key; 131 | exec(" pbcopy < " + publicKeyPath, (err, stdout, stderr) => { 132 | if (err) { 133 | console.error(`exec error: ${err}`); 134 | return; 135 | } 136 | 137 | mainWindow.webContents.send("copied:key", { publicKey: stdout, key }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluprince13/ssh-key-manager/3d31bfdb8441fffd029cd6808a70ed21bb06d423/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | 23 | SSH key manager 24 | 25 | 26 | 27 |
28 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "SSH key manager", 3 | "name": "SSH key manager", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::after, 3 | *::before { 4 | margin: 0; 5 | padding: 0; 6 | box-sizing: inherit; 7 | 8 | min-height: 0; 9 | min-width: 0; 10 | } 11 | 12 | html { 13 | font-size: 62.5%; 14 | } 15 | 16 | body { 17 | box-sizing: border-box; 18 | } 19 | 20 | body { 21 | font-family: "Lato", sans-serif; 22 | font-weight: 400; 23 | line-height: 1.7; 24 | color: #777; 25 | padding: 0 3rem; 26 | max-width: 114rem; 27 | margin: auto auto; 28 | } 29 | 30 | .clearfix::after { 31 | content: ""; 32 | clear: both; 33 | display: table; 34 | } -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Router, Route, Switch } from "react-router-dom"; 3 | import { Provider } from "react-redux"; 4 | import history from "./helpers/history"; 5 | 6 | import store from "./reducers"; 7 | 8 | import Browser from "./screens/Browser"; 9 | import AddForm from "./screens/AddForm"; 10 | import Header from "./components/Header"; 11 | 12 | import { ThemeProvider } from "styled-components"; 13 | 14 | const theme = { 15 | lightest: "#e0f7fa", 16 | light: "#b2ebf2", 17 | highlight: "red", 18 | dark: "#4dd0e1", 19 | darkest: "#0097a7", 20 | 21 | small: "0.75rem", 22 | medium: "1rem" 23 | }; 24 | 25 | export default function App() { 26 | return ( 27 | 28 | 29 | 30 |
31 |
32 | 33 | 34 | 35 | 36 |
37 |
38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { GET_KEYS, REMOVE_KEY, COPY_KEY } from "./types"; 3 | import history from "../helpers/history"; 4 | import { copyToClipboard } from "../helpers"; 5 | 6 | export const getKeys = () => dispatch => { 7 | ipcRenderer.send("get:keys"); 8 | 9 | ipcRenderer.on("received:keys", (event, keys) => { 10 | dispatch({ type: GET_KEYS, payload: { keys } }); 11 | }); 12 | }; 13 | 14 | export const addKey = key => dispatch => { 15 | ipcRenderer.send("add:key", key); 16 | 17 | ipcRenderer.on("added:key", (event, key) => { 18 | history.push("/"); 19 | }); 20 | }; 21 | 22 | export const removeKey = key => dispatch => { 23 | ipcRenderer.send("remove:key", key); 24 | 25 | ipcRenderer.on("removed:key", (event, key) => { 26 | dispatch({ type: REMOVE_KEY, payload: { key } }); 27 | }); 28 | }; 29 | 30 | export const copyKey = key => dispatch => { 31 | ipcRenderer.send("copy:key", key); 32 | 33 | ipcRenderer.on("copied:key", (event, { key, publicKey }) => { 34 | copyToClipboard(publicKey); 35 | dispatch({ type: COPY_KEY, payload: { key, publicKey } }); 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /src/actions/types.js: -------------------------------------------------------------------------------- 1 | export const GET_KEYS = "get_keys"; 2 | export const ADD_KEY = "add_key"; 3 | export const REMOVE_KEY = "remove_key"; 4 | export const COPY_KEY = "copy_key"; 5 | -------------------------------------------------------------------------------- /src/components/AddButton.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { withRouter } from "react-router-dom"; 3 | 4 | import { StyledButton, StyledIcon } from "../components/styled"; 5 | class AddButton extends Component { 6 | render() { 7 | return ( 8 | { 11 | this.props.history.push("/add"); 12 | }} 13 | > 14 | 15 | 16 | ); 17 | } 18 | } 19 | 20 | export default withRouter(AddButton); 21 | -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import styled from "styled-components"; 3 | import { Link } from "react-router-dom"; 4 | 5 | const StyledHeader = styled.div` 6 | position: relative; 7 | 8 | padding: 1rem; 9 | text-align: left; 10 | background: #00bcd4; 11 | font-size: 3rem; 12 | 13 | margin-bottom: 1rem; 14 | `; 15 | 16 | class Header extends Component { 17 | render() { 18 | return ( 19 | 20 | 24 | SSH key manager 25 | 26 | 27 | ); 28 | } 29 | } 30 | 31 | export default Header; 32 | -------------------------------------------------------------------------------- /src/components/KeyList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import styled from "styled-components"; 3 | 4 | import ReactTooltip from "react-tooltip"; 5 | import Tooltip from "./Tooltip"; 6 | 7 | const StyledRow = styled.li` 8 | background: ${props => props.theme.lightest}; 9 | font-size: ${props => props.theme.medium}; 10 | 11 | position: relative; 12 | display: block; 13 | padding: 0.75rem 1.25rem; 14 | margin-bottom: -1px; 15 | border: 1px solid rgba(0, 0, 0, 0.125); 16 | 17 | &:hover { 18 | background: ${props => props.theme.light}; 19 | } 20 | 21 | &:first-child { 22 | border-top-left-radius: 0.25rem; 23 | border-top-right-radius: 0.25rem; 24 | } 25 | 26 | &:last-child { 27 | margin-bottom: 0; 28 | border-bottom-right-radius: 0.25rem; 29 | border-bottom-left-radius: 0.25rem; 30 | } 31 | `; 32 | 33 | const StyledIcon = styled.i` 34 | &: hover { 35 | color: ${props => props.theme.highlight}; 36 | } 37 | `; 38 | 39 | const StyledListGroup = styled.ul` 40 | display: -webkit-box; 41 | display: -ms-flexbox; 42 | display: flex; 43 | -webkit-box-orient: vertical; 44 | -webkit-box-direction: normal; 45 | -ms-flex-direction: column; 46 | flex-direction: column; 47 | padding-left: 0; 48 | margin-bottom: 0; 49 | `; 50 | 51 | class KeyList extends Component { 52 | componentDidUpdate() { 53 | ReactTooltip.rebuild(); 54 | } 55 | 56 | handleCopy(key) { 57 | this.props.copyKey(key); 58 | 59 | setTimeout(() => { 60 | ReactTooltip.hide(); 61 | }, 1500); 62 | } 63 | 64 | handleRemove(key) { 65 | this.props.removeKey(key); 66 | ReactTooltip.hide(); 67 | } 68 | 69 | renderKeys() { 70 | const keys = this.props.keys; 71 | 72 | return keys.map(key => { 73 | const { privateKeyFilename } = key; 74 | return ( 75 | 76 |
77 | {privateKeyFilename} 78 | 79 | { 81 | this.handleCopy(key); 82 | }} 83 | data-tip="Public key copied" 84 | data-event="click" 85 | data-for="copied" 86 | > 87 | 88 | 89 | 90 | 91 | 92 |
93 |
94 | { 98 | this.handleRemove(key); 99 | }} 100 | > 101 | 102 | 103 | 104 |
105 |
106 | ); 107 | }); 108 | } 109 | 110 | render() { 111 | return {this.renderKeys()}; 112 | } 113 | } 114 | 115 | export default KeyList; 116 | -------------------------------------------------------------------------------- /src/components/Tooltip.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import ReactTooltip from "react-tooltip"; 3 | 4 | export default class Tooltip extends Component { 5 | render() { 6 | return ( 7 | 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/styled.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const StyledIcon = styled.i``; 4 | 5 | export const StyledButton = styled.button` 6 | background-color: ${props => props.theme.light}; 7 | display: block; 8 | 9 | height: 3rem; 10 | width: 3rem; 11 | border-radius: 50%; 12 | 13 | &: hover { 14 | background-color: ${props => props.theme.dark}; 15 | 16 | ${StyledIcon} { 17 | color: ${props => props.theme.highlight}; 18 | } 19 | } 20 | 21 | &: focus { 22 | outline: none; 23 | border: none; 24 | } 25 | 26 | &: active { 27 | background-color: ${props => props.theme.darkest}; 28 | } 29 | `; 30 | 31 | -------------------------------------------------------------------------------- /src/helpers/history.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history' 2 | 3 | export default createBrowserHistory({ 4 | /* pass a configuration object here if needed */ 5 | }) -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | export const copyToClipboard = text => { 2 | var dummy = document.createElement("input"); 3 | document.body.appendChild(dummy); 4 | dummy.setAttribute('value', text); 5 | dummy.select(); 6 | document.execCommand("copy"); 7 | document.body.removeChild(dummy); 8 | }; 9 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers, createStore, applyMiddleware } from "redux"; 2 | import thunk from "redux-thunk"; 3 | import { reducer as formReducer } from "redux-form"; 4 | 5 | import { composeWithDevTools } from "redux-devtools-extension"; 6 | 7 | import keysReducer from "./keys_reducer"; 8 | 9 | const rootReducer = combineReducers({ 10 | keys: keysReducer, 11 | form: formReducer 12 | }); 13 | 14 | const store = createStore( 15 | rootReducer, 16 | composeWithDevTools(applyMiddleware(thunk)) 17 | ); 18 | 19 | export default store; 20 | -------------------------------------------------------------------------------- /src/reducers/keys_reducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | GET_KEYS, 3 | REMOVE_KEY, 4 | } from "../actions/types"; 5 | 6 | const INITIAL_STATE = []; 7 | 8 | export default (state = INITIAL_STATE, action) => { 9 | switch (action.type) { 10 | case GET_KEYS: 11 | const { keys } = action.payload; 12 | return keys; 13 | case REMOVE_KEY: 14 | const { key: keyToRemove } = action.payload; 15 | const new_keys = state.filter(key => { 16 | return ( 17 | key.privateKeyFilename !== keyToRemove.privateKeyFilename 18 | ); 19 | }); 20 | return new_keys; 21 | default: 22 | return state; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/screens/AddForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { connect } from "react-redux"; 3 | import { Field, reduxForm } from "redux-form"; 4 | import { withRouter } from "react-router-dom"; 5 | import styled, { keyframes, css } from "styled-components"; 6 | 7 | import * as actions from "../actions"; 8 | import Tooltip from "../components/Tooltip"; 9 | import { StyledButton, StyledIcon } from "../components/styled"; 10 | 11 | const StyledForm = styled.form` 12 | margin: 1rem; 13 | `; 14 | 15 | const StyledFieldGroup = styled.div` 16 | display: block; 17 | `; 18 | 19 | const StyledField = styled.div` 20 | margin-bottom: 1rem; 21 | position: relative; 22 | `; 23 | 24 | const StyledLabel = styled.label` 25 | font-size: ${props => props.theme.small}; 26 | `; 27 | 28 | const StyledInput = styled.input` 29 | display: block; 30 | 31 | margin-top: 0.4rem; 32 | 33 | width: 100%; 34 | -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ 35 | -moz-box-sizing: border-box; /* Firefox, other Gecko */ 36 | box-sizing: border-box; 37 | padding: 0.375rem 0.75rem; 38 | 39 | font-size: ${props => props.theme.medium}; 40 | line-height: 1.5; 41 | color: #495057; 42 | background-color: ${props => props.theme.lightest} 43 | background-clip: padding-box; 44 | border: 1px solid #ced4da; 45 | border-radius: 0.25rem; 46 | transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 47 | 48 | &::placeholder { 49 | font-size: ${props => props.theme.small}; 50 | } 51 | 52 | &:focus { 53 | color: #495057; 54 | background-color: #fff; 55 | border-color: #80bdff; 56 | outline: 0; 57 | box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25); 58 | } 59 | `; 60 | 61 | const StyledError = styled.span` 62 | color: ${props => props.theme.highlight}; 63 | font-size: ${props => props.theme.small}; 64 | `; 65 | 66 | const StyledButtonGroup = styled.div` 67 | margin: 1rem; 68 | `; 69 | 70 | const shadowpulse = keyframes` 71 | 0% { 72 | box-shadow: 0 0 0 0px rgba(0, 0, 0, 0.2); 73 | } 74 | 100% { 75 | box-shadow: 0 0 0 1rem rgba(0, 0, 0, 0); 76 | } 77 | `; 78 | 79 | const LoadingButton = styled(StyledButton)` 80 | animation: ${props => 81 | props.submitted 82 | ? css` 83 | ${shadowpulse} 1s infinite 84 | ` 85 | : ""}; 86 | `; 87 | 88 | const validate = (values, props) => { 89 | const errors = {}; 90 | if (!values.filename) { 91 | errors.filename = "Required"; 92 | } 93 | 94 | const { keys } = props; 95 | const isFilenameUsed = keys.filter( 96 | key => key.privateKeyFilename === values.filename 97 | ); 98 | if (isFilenameUsed.length !== 0) { 99 | errors.filename = "This filename is already taken"; 100 | } 101 | 102 | return errors; 103 | }; 104 | 105 | const renderField = ({ input, label, type, meta: { touched, error } }) => ( 106 | 107 | {label} 108 | 109 | {touched && error && {error}} 110 | 111 | ); 112 | 113 | class AddForm extends Component { 114 | state = { 115 | submitted: false 116 | }; 117 | 118 | componentDidMount() { 119 | const { dispatch, change } = this.props; 120 | const { keys } = this.props; 121 | const isDefaultUsed = keys.filter( 122 | key => key.privateKeyFilename === "id_rsa" 123 | ); 124 | if (isDefaultUsed.length === 0) { 125 | dispatch(change("filename", "id_rsa")); 126 | } 127 | } 128 | 129 | render() { 130 | const { handleSubmit, pristine, reset, submitting } = this.props; 131 | 132 | const goHome = () => { 133 | this.props.history.push("/"); 134 | }; 135 | 136 | const submit = values => { 137 | this.submitted = true; 138 | this.props.addKey(values); 139 | }; 140 | 141 | return ( 142 | 143 | 144 | 150 | 156 | 162 | 163 | 164 | 165 | 172 | 173 | 174 | 175 | 183 | 184 | 185 | 186 | 194 | 195 | 196 | 197 | 198 | 199 | ); 200 | } 201 | } 202 | 203 | function mapStateToProps(state) { 204 | return { keys: state.keys }; 205 | } 206 | 207 | AddForm = reduxForm({ 208 | form: "AddForm", // a unique identifier for this form 209 | validate, // <--- validation function given to redux-form 210 | initialValues: { 211 | passphrase: "" 212 | } 213 | })(AddForm); 214 | AddForm = connect( 215 | mapStateToProps, 216 | actions 217 | )(AddForm); 218 | AddForm = withRouter(AddForm); 219 | 220 | export default AddForm; 221 | -------------------------------------------------------------------------------- /src/screens/Browser.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { connect } from "react-redux"; 3 | import styled from "styled-components"; 4 | 5 | import KeyList from "../components/KeyList"; 6 | import * as actions from "../actions"; 7 | import AddButton from "../components/AddButton"; 8 | 9 | const StyledContainer = styled.div` 10 | margin: 1rem; 11 | `; 12 | class Browser extends Component { 13 | componentWillMount() { 14 | this.props.getKeys(); 15 | } 16 | 17 | render() { 18 | return ( 19 | 20 | 25 | 26 | 27 | ); 28 | } 29 | } 30 | 31 | function mapStateToProps(state) { 32 | return { keys: state.keys }; 33 | } 34 | 35 | export default connect( 36 | mapStateToProps, 37 | actions 38 | )(Browser); 39 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | --------------------------------------------------------------------------------