├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── app.js ├── bin └── www ├── db.js ├── package.json ├── public └── css │ ├── app.css │ ├── base.css │ ├── home.css │ ├── index.css │ └── login.css ├── routes ├── auth.js └── index.js └── views ├── error.ejs ├── home.ejs ├── index.ejs └── login.ejs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: jaredhanson 2 | patreon: jaredhanson 3 | ko_fi: jaredhanson 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | var 3 | 4 | # Node.js 5 | node_modules/ 6 | npm-debug.log* 7 | 8 | # Mac OS X 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # todos-express-google-oauth2 2 | 3 | This app illustrates how to use [Passport](https://www.passportjs.org/) with 4 | [Express](https://expressjs.com/) to sign users in with [Google](https://www.google.com/). 5 | Use this example as a starting point for your own web applications. 6 | 7 | ## Quick Start 8 | 9 | To run this app, clone the repository and install dependencies: 10 | 11 | ```bash 12 | $ git clone https://github.com/passport/todos-express-google-oauth2.git 13 | $ cd todos-express-google-oauth2 14 | $ npm install 15 | ``` 16 | 17 | This app requires OAuth 2.0 credentials from Google, which can be obtained by 18 | [setting up](https://developers.google.com/identity/protocols/oauth2/openid-connect#appsetup) 19 | a project in [Google API console](https://console.developers.google.com/apis/). 20 | The redirect URI of the OAuth client should be set to `'http://localhost:3000/oauth2/redirect/google'`. 21 | 22 | Once credentials have been obtained, create a `.env` file and add the following 23 | environment variables: 24 | 25 | ``` 26 | GOOGLE_CLIENT_ID=__INSERT_CLIENT_ID_HERE__ 27 | GOOGLE_CLIENT_SECRET=__INSERT_CLIENT_SECRET_HERE__ 28 | ``` 29 | 30 | Start the server. 31 | 32 | ```bash 33 | $ npm start 34 | ``` 35 | 36 | Navigate to [`http://localhost:3000`](http://localhost:3000). 37 | 38 | ## Overview 39 | 40 | This example illustrates how to use Passport and the [`passport-google-oauth20`](https://www.passportjs.org/packages/passport-google-oauth20/) 41 | strategy within an Express application to sign users in with [Google](https://www.google.com) 42 | via OAuth 2.0. 43 | 44 | This app implements the features of a typical [TodoMVC](https://todomvc.com/) 45 | app, and adds sign in functionality. This app is a traditional web application, 46 | in which all application logic and data persistence is handled on the server. 47 | 48 | User interaction is performed via HTML pages and forms, which are rendered via 49 | [EJS](https://ejs.co/) templates and styled with vanilla CSS. Data is stored in 50 | and queried from a [SQLite](https://www.sqlite.org/) database. 51 | 52 | After users sign in, a login session is established and maintained between the 53 | server and the browser with a cookie. As authenticated users interact with the 54 | app, creating and editing todo items, the login state is restored by 55 | authenticating the session. 56 | 57 | ## License 58 | 59 | [The Unlicense](https://opensource.org/licenses/unlicense) 60 | 61 | ## Credit 62 | 63 | Created by [Jared Hanson](https://www.jaredhanson.me/) 64 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | var createError = require('http-errors'); 4 | var express = require('express'); 5 | var path = require('path'); 6 | var cookieParser = require('cookie-parser'); 7 | var session = require('express-session'); 8 | var csrf = require('csurf'); 9 | var passport = require('passport'); 10 | var logger = require('morgan'); 11 | 12 | // pass the session to the connect sqlite3 module 13 | // allowing it to inherit from session.Store 14 | var SQLiteStore = require('connect-sqlite3')(session); 15 | 16 | var indexRouter = require('./routes/index'); 17 | var authRouter = require('./routes/auth'); 18 | 19 | var app = express(); 20 | 21 | // view engine setup 22 | app.set('views', path.join(__dirname, 'views')); 23 | app.set('view engine', 'ejs'); 24 | 25 | app.locals.pluralize = require('pluralize'); 26 | 27 | app.use(logger('dev')); 28 | app.use(express.json()); 29 | app.use(express.urlencoded({ extended: false })); 30 | app.use(cookieParser()); 31 | app.use(express.static(path.join(__dirname, 'public'))); 32 | app.use(session({ 33 | secret: 'keyboard cat', 34 | resave: false, // don't save session if unmodified 35 | saveUninitialized: false, // don't create session until something stored 36 | store: new SQLiteStore({ db: 'sessions.db', dir: 'var/db' }) 37 | })); 38 | app.use(csrf()); 39 | app.use(passport.authenticate('session')); 40 | app.use(function(req, res, next) { 41 | var msgs = req.session.messages || []; 42 | res.locals.messages = msgs; 43 | res.locals.hasMessages = !! msgs.length; 44 | req.session.messages = []; 45 | next(); 46 | }); 47 | app.use(function(req, res, next) { 48 | res.locals.csrfToken = req.csrfToken(); 49 | next(); 50 | }); 51 | 52 | app.use('/', indexRouter); 53 | app.use('/', authRouter); 54 | 55 | // catch 404 and forward to error handler 56 | app.use(function(req, res, next) { 57 | next(createError(404)); 58 | }); 59 | 60 | // error handler 61 | app.use(function(err, req, res, next) { 62 | // set locals, only providing error in development 63 | res.locals.message = err.message; 64 | res.locals.error = req.app.get('env') === 'development' ? err : {}; 65 | 66 | // render the error page 67 | res.status(err.status || 500); 68 | res.render('error'); 69 | }); 70 | 71 | module.exports = app; 72 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('../app'); 8 | var debug = require('debug')('todos:server'); 9 | var http = require('http'); 10 | 11 | /** 12 | * Get port from environment and store in Express. 13 | */ 14 | 15 | var port = normalizePort(process.env.PORT || '3000'); 16 | app.set('port', port); 17 | 18 | /** 19 | * Create HTTP server. 20 | */ 21 | 22 | var server = http.createServer(app); 23 | 24 | /** 25 | * Listen on provided port, on all network interfaces. 26 | */ 27 | 28 | server.listen(port); 29 | server.on('error', onError); 30 | server.on('listening', onListening); 31 | 32 | /** 33 | * Normalize a port into a number, string, or false. 34 | */ 35 | 36 | function normalizePort(val) { 37 | var port = parseInt(val, 10); 38 | 39 | if (isNaN(port)) { 40 | // named pipe 41 | return val; 42 | } 43 | 44 | if (port >= 0) { 45 | // port number 46 | return port; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | /** 53 | * Event listener for HTTP server "error" event. 54 | */ 55 | 56 | function onError(error) { 57 | if (error.syscall !== 'listen') { 58 | throw error; 59 | } 60 | 61 | var bind = typeof port === 'string' 62 | ? 'Pipe ' + port 63 | : 'Port ' + port; 64 | 65 | // handle specific listen errors with friendly messages 66 | switch (error.code) { 67 | case 'EACCES': 68 | console.error(bind + ' requires elevated privileges'); 69 | process.exit(1); 70 | break; 71 | case 'EADDRINUSE': 72 | console.error(bind + ' is already in use'); 73 | process.exit(1); 74 | break; 75 | default: 76 | throw error; 77 | } 78 | } 79 | 80 | /** 81 | * Event listener for HTTP server "listening" event. 82 | */ 83 | 84 | function onListening() { 85 | var addr = server.address(); 86 | var bind = typeof addr === 'string' 87 | ? 'pipe ' + addr 88 | : 'port ' + addr.port; 89 | debug('Listening on ' + bind); 90 | } 91 | -------------------------------------------------------------------------------- /db.js: -------------------------------------------------------------------------------- 1 | var sqlite3 = require('sqlite3'); 2 | var mkdirp = require('mkdirp'); 3 | 4 | mkdirp.sync('var/db'); 5 | 6 | var db = new sqlite3.Database('var/db/todos.db'); 7 | 8 | db.serialize(function() { 9 | db.run("CREATE TABLE IF NOT EXISTS users ( \ 10 | username TEXT UNIQUE, \ 11 | hashed_password BLOB, \ 12 | salt BLOB, \ 13 | name TEXT \ 14 | )"); 15 | 16 | db.run("CREATE TABLE IF NOT EXISTS federated_credentials ( \ 17 | user_id INTEGER NOT NULL, \ 18 | provider TEXT NOT NULL, \ 19 | subject TEXT NOT NULL, \ 20 | PRIMARY KEY (provider, subject) \ 21 | )"); 22 | 23 | db.run("CREATE TABLE IF NOT EXISTS todos ( \ 24 | owner_id INTEGER NOT NULL, \ 25 | title TEXT NOT NULL, \ 26 | completed INTEGER \ 27 | )"); 28 | }); 29 | 30 | module.exports = db; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todos-express-google-oauth2", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Todo app using Express, Passport, and SQLite for sign in with Google.", 6 | "keywords": [ 7 | "example", 8 | "express", 9 | "passport", 10 | "sqlite" 11 | ], 12 | "author": { 13 | "name": "Jared Hanson", 14 | "email": "jaredhanson@gmail.com", 15 | "url": "https://www.jaredhanson.me/" 16 | }, 17 | "homepage": "https://github.com/passport/todos-express-google-oauth2", 18 | "repository": { 19 | "type": "git", 20 | "url": "git://github.com/passport/todos-express-google-oauth2.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/passport/todos-express-google-oauth2/issues" 24 | }, 25 | "funding": { 26 | "type": "github", 27 | "url": "https://github.com/sponsors/jaredhanson" 28 | }, 29 | "license": "Unlicense", 30 | "scripts": { 31 | "start": "node ./bin/www" 32 | }, 33 | "dependencies": { 34 | "connect-ensure-login": "^0.1.1", 35 | "connect-sqlite3": "^0.9.13", 36 | "cookie-parser": "~1.4.4", 37 | "csurf": "^1.11.0", 38 | "debug": "~2.6.9", 39 | "dotenv": "^8.6.0", 40 | "ejs": "~2.6.1", 41 | "express": "~4.16.1", 42 | "express-session": "^1.17.2", 43 | "http-errors": "~1.6.3", 44 | "mkdirp": "^1.0.4", 45 | "morgan": "~1.9.1", 46 | "passport": "^0.5.2", 47 | "passport-google-oauth20": "^2.0.0", 48 | "pluralize": "^8.0.0", 49 | "sqlite3": "^5.0.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /public/css/app.css: -------------------------------------------------------------------------------- 1 | .nav { 2 | position: absolute; 3 | top: -130px; 4 | right: 0; 5 | } 6 | 7 | .nav ul { 8 | margin: 0; 9 | list-style: none; 10 | text-align: center; 11 | } 12 | 13 | .nav li { 14 | display: inline-block; 15 | height: 40px; 16 | margin-left: 12px; 17 | font-size: 14px; 18 | font-weight: 400; 19 | line-height: 40px; 20 | } 21 | 22 | .nav a { 23 | display: block; 24 | color: inherit; 25 | text-decoration: none; 26 | } 27 | 28 | .nav a:hover { 29 | border-bottom: 1px solid #DB7676; 30 | } 31 | 32 | .nav button { 33 | height: 40px; 34 | } 35 | 36 | .nav button:hover { 37 | border-bottom: 1px solid #DB7676; 38 | cursor: pointer; 39 | } 40 | 41 | /* background image by Cole Bemis */ 42 | .nav .user { 43 | padding-left: 20px; 44 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-user'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'%3E%3C/path%3E%3Ccircle cx='12' cy='7' r='4'%3E%3C/circle%3E%3C/svg%3E"); 45 | background-repeat: no-repeat; 46 | background-position: center left; 47 | } 48 | 49 | /* background image by Cole Bemis */ 50 | .nav .logout { 51 | padding-left: 20px; 52 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-log-out'%3E%3Cpath d='M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4'%3E%3C/path%3E%3Cpolyline points='16 17 21 12 16 7'%3E%3C/polyline%3E%3Cline x1='21' y1='12' x2='9' y2='12'%3E%3C/line%3E%3C/svg%3E%0A"); 53 | background-repeat: no-repeat; 54 | background-position: center left; 55 | } 56 | -------------------------------------------------------------------------------- /public/css/base.css: -------------------------------------------------------------------------------- 1 | hr { 2 | margin: 20px 0; 3 | border: 0; 4 | border-top: 1px dashed #c5c5c5; 5 | border-bottom: 1px dashed #f7f7f7; 6 | } 7 | 8 | .learn a { 9 | font-weight: normal; 10 | text-decoration: none; 11 | color: #b83f45; 12 | } 13 | 14 | .learn a:hover { 15 | text-decoration: underline; 16 | color: #787e7e; 17 | } 18 | 19 | .learn h3, 20 | .learn h4, 21 | .learn h5 { 22 | margin: 10px 0; 23 | font-weight: 500; 24 | line-height: 1.2; 25 | color: #000; 26 | } 27 | 28 | .learn h3 { 29 | font-size: 24px; 30 | } 31 | 32 | .learn h4 { 33 | font-size: 18px; 34 | } 35 | 36 | .learn h5 { 37 | margin-bottom: 0; 38 | font-size: 14px; 39 | } 40 | 41 | .learn ul { 42 | padding: 0; 43 | margin: 0 0 30px 25px; 44 | } 45 | 46 | .learn li { 47 | line-height: 20px; 48 | } 49 | 50 | .learn p { 51 | font-size: 15px; 52 | font-weight: 300; 53 | line-height: 1.3; 54 | margin-top: 0; 55 | margin-bottom: 0; 56 | } 57 | 58 | #issue-count { 59 | display: none; 60 | } 61 | 62 | .quote { 63 | border: none; 64 | margin: 20px 0 60px 0; 65 | } 66 | 67 | .quote p { 68 | font-style: italic; 69 | } 70 | 71 | .quote p:before { 72 | content: '“'; 73 | font-size: 50px; 74 | opacity: .15; 75 | position: absolute; 76 | top: -20px; 77 | left: 3px; 78 | } 79 | 80 | .quote p:after { 81 | content: '”'; 82 | font-size: 50px; 83 | opacity: .15; 84 | position: absolute; 85 | bottom: -42px; 86 | right: 3px; 87 | } 88 | 89 | .quote footer { 90 | position: absolute; 91 | bottom: -40px; 92 | right: 0; 93 | } 94 | 95 | .quote footer img { 96 | border-radius: 3px; 97 | } 98 | 99 | .quote footer a { 100 | margin-left: 5px; 101 | vertical-align: middle; 102 | } 103 | 104 | .speech-bubble { 105 | position: relative; 106 | padding: 10px; 107 | background: rgba(0, 0, 0, .04); 108 | border-radius: 5px; 109 | } 110 | 111 | .speech-bubble:after { 112 | content: ''; 113 | position: absolute; 114 | top: 100%; 115 | right: 30px; 116 | border: 13px solid transparent; 117 | border-top-color: rgba(0, 0, 0, .04); 118 | } 119 | 120 | .learn-bar > .learn { 121 | position: absolute; 122 | width: 272px; 123 | top: 8px; 124 | left: -300px; 125 | padding: 10px; 126 | border-radius: 5px; 127 | background-color: rgba(255, 255, 255, .6); 128 | transition-property: left; 129 | transition-duration: 500ms; 130 | } 131 | 132 | @media (min-width: 899px) { 133 | .learn-bar { 134 | width: auto; 135 | padding-left: 300px; 136 | } 137 | 138 | .learn-bar > .learn { 139 | left: 8px; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /public/css/home.css: -------------------------------------------------------------------------------- 1 | .todohome { 2 | margin: 130px 0 40px 0; 3 | position: relative; 4 | } 5 | 6 | .todohome h1 { 7 | position: absolute; 8 | top: -140px; 9 | width: 100%; 10 | font-size: 80px; 11 | font-weight: 200; 12 | text-align: center; 13 | color: #b83f45; 14 | -webkit-text-rendering: optimizeLegibility; 15 | -moz-text-rendering: optimizeLegibility; 16 | text-rendering: optimizeLegibility; 17 | } 18 | 19 | .todohome section { 20 | padding-top: 1px; 21 | text-align: center; 22 | } 23 | 24 | .todohome h2 { 25 | padding-bottom: 48px; 26 | font-size: 28px; 27 | font-weight: 300; 28 | } 29 | 30 | .todohome .button { 31 | padding: 13px 45px; 32 | font-size: 16px; 33 | font-weight: 500; 34 | color: white; 35 | border-radius: 5px; 36 | background: #d83f45; 37 | } 38 | 39 | .todohome a.button { 40 | text-decoration: none; 41 | } 42 | -------------------------------------------------------------------------------- /public/css/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | button { 8 | margin: 0; 9 | padding: 0; 10 | border: 0; 11 | background: none; 12 | font-size: 100%; 13 | vertical-align: baseline; 14 | font-family: inherit; 15 | font-weight: inherit; 16 | color: inherit; 17 | -webkit-appearance: none; 18 | appearance: none; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | } 22 | 23 | body { 24 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 25 | line-height: 1.4em; 26 | background: #f5f5f5; 27 | color: #111111; 28 | min-width: 230px; 29 | max-width: 550px; 30 | margin: 0 auto; 31 | -webkit-font-smoothing: antialiased; 32 | -moz-osx-font-smoothing: grayscale; 33 | font-weight: 300; 34 | } 35 | 36 | .hidden { 37 | display: none; 38 | } 39 | 40 | .todoapp { 41 | background: #fff; 42 | margin: 130px 0 40px 0; 43 | position: relative; 44 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 45 | 0 25px 50px 0 rgba(0, 0, 0, 0.1); 46 | } 47 | 48 | .todoapp input::-webkit-input-placeholder { 49 | font-style: italic; 50 | font-weight: 400; 51 | color: rgba(0, 0, 0, 0.4); 52 | } 53 | 54 | .todoapp input::-moz-placeholder { 55 | font-style: italic; 56 | font-weight: 400; 57 | color: rgba(0, 0, 0, 0.4); 58 | } 59 | 60 | .todoapp input::input-placeholder { 61 | font-style: italic; 62 | font-weight: 400; 63 | color: rgba(0, 0, 0, 0.4); 64 | } 65 | 66 | .todoapp h1 { 67 | position: absolute; 68 | top: -140px; 69 | width: 100%; 70 | font-size: 80px; 71 | font-weight: 200; 72 | text-align: center; 73 | color: #b83f45; 74 | -webkit-text-rendering: optimizeLegibility; 75 | -moz-text-rendering: optimizeLegibility; 76 | text-rendering: optimizeLegibility; 77 | } 78 | 79 | .new-todo, 80 | .edit { 81 | position: relative; 82 | margin: 0; 83 | width: 100%; 84 | font-size: 24px; 85 | font-family: inherit; 86 | font-weight: inherit; 87 | line-height: 1.4em; 88 | color: inherit; 89 | padding: 6px; 90 | border: 1px solid #999; 91 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 92 | box-sizing: border-box; 93 | -webkit-font-smoothing: antialiased; 94 | -moz-osx-font-smoothing: grayscale; 95 | } 96 | 97 | .new-todo { 98 | padding: 16px 16px 16px 60px; 99 | height: 65px; 100 | border: none; 101 | background: rgba(0, 0, 0, 0.003); 102 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); 103 | } 104 | 105 | .main { 106 | position: relative; 107 | z-index: 2; 108 | border-top: 1px solid #e6e6e6; 109 | } 110 | 111 | .toggle-all { 112 | width: 1px; 113 | height: 1px; 114 | border: none; /* Mobile Safari */ 115 | opacity: 0; 116 | position: absolute; 117 | right: 100%; 118 | bottom: 100%; 119 | } 120 | 121 | .toggle-all + label { 122 | display: flex; 123 | align-items: center; 124 | justify-content: center; 125 | width: 45px; 126 | height: 65px; 127 | font-size: 0; 128 | position: absolute; 129 | top: -65px; 130 | left: -0; 131 | } 132 | 133 | .toggle-all + label:before { 134 | content: '❯'; 135 | display: inline-block; 136 | font-size: 22px; 137 | color: #949494; 138 | padding: 10px 27px 10px 27px; 139 | -webkit-transform: rotate(90deg); 140 | transform: rotate(90deg); 141 | } 142 | 143 | .toggle-all:checked + label:before { 144 | color: #484848; 145 | } 146 | 147 | .todo-list { 148 | margin: 0; 149 | padding: 0; 150 | list-style: none; 151 | } 152 | 153 | .todo-list li { 154 | position: relative; 155 | font-size: 24px; 156 | border-bottom: 1px solid #ededed; 157 | } 158 | 159 | .todo-list li:last-child { 160 | border-bottom: none; 161 | } 162 | 163 | .todo-list li.editing { 164 | border-bottom: none; 165 | padding: 0; 166 | } 167 | 168 | .todo-list li.editing .edit { 169 | display: block; 170 | width: calc(100% - 43px); 171 | padding: 12px 16px; 172 | margin: 0 0 0 43px; 173 | } 174 | 175 | .todo-list li.editing .view { 176 | display: none; 177 | } 178 | 179 | .todo-list li .toggle { 180 | text-align: center; 181 | width: 40px; 182 | /* auto, since non-WebKit browsers doesn't support input styling */ 183 | height: auto; 184 | position: absolute; 185 | top: 0; 186 | bottom: 0; 187 | margin: auto 0; 188 | border: none; /* Mobile Safari */ 189 | -webkit-appearance: none; 190 | appearance: none; 191 | } 192 | 193 | .todo-list li .toggle { 194 | opacity: 0; 195 | } 196 | 197 | .todo-list li .toggle + label { 198 | /* 199 | Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 200 | IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ 201 | */ 202 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); 203 | background-repeat: no-repeat; 204 | background-position: center left; 205 | } 206 | 207 | .todo-list li .toggle:checked + label { 208 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E'); 209 | } 210 | 211 | .todo-list li label { 212 | word-break: break-all; 213 | padding: 15px 15px 15px 60px; 214 | display: block; 215 | line-height: 1.2; 216 | transition: color 0.4s; 217 | font-weight: 400; 218 | color: #484848; 219 | } 220 | 221 | .todo-list li.completed label { 222 | color: #949494; 223 | text-decoration: line-through; 224 | } 225 | 226 | .todo-list li .destroy { 227 | display: none; 228 | position: absolute; 229 | top: 0; 230 | right: 10px; 231 | bottom: 0; 232 | width: 40px; 233 | height: 40px; 234 | margin: auto 0; 235 | font-size: 30px; 236 | color: #949494; 237 | transition: color 0.2s ease-out; 238 | } 239 | 240 | .todo-list li .destroy:hover, 241 | .todo-list li .destroy:focus { 242 | color: #C18585; 243 | } 244 | 245 | .todo-list li .destroy:after { 246 | content: '×'; 247 | display: block; 248 | height: 100%; 249 | line-height: 1.1; 250 | } 251 | 252 | .todo-list li:hover .destroy { 253 | display: block; 254 | } 255 | 256 | .todo-list li .edit { 257 | display: none; 258 | } 259 | 260 | .todo-list li.editing:last-child { 261 | margin-bottom: -1px; 262 | } 263 | 264 | .footer { 265 | padding: 10px 15px; 266 | height: 20px; 267 | text-align: center; 268 | font-size: 15px; 269 | border-top: 1px solid #e6e6e6; 270 | } 271 | 272 | .footer:before { 273 | content: ''; 274 | position: absolute; 275 | right: 0; 276 | bottom: 0; 277 | left: 0; 278 | height: 50px; 279 | overflow: hidden; 280 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 281 | 0 8px 0 -3px #f6f6f6, 282 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 283 | 0 16px 0 -6px #f6f6f6, 284 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 285 | } 286 | 287 | .todo-count { 288 | float: left; 289 | text-align: left; 290 | } 291 | 292 | .todo-count strong { 293 | font-weight: 300; 294 | } 295 | 296 | .filters { 297 | margin: 0; 298 | padding: 0; 299 | list-style: none; 300 | position: absolute; 301 | right: 0; 302 | left: 0; 303 | } 304 | 305 | .filters li { 306 | display: inline; 307 | } 308 | 309 | .filters li a { 310 | color: inherit; 311 | margin: 3px; 312 | padding: 3px 7px; 313 | text-decoration: none; 314 | border: 1px solid transparent; 315 | border-radius: 3px; 316 | } 317 | 318 | .filters li a:hover { 319 | border-color: #DB7676; 320 | } 321 | 322 | .filters li a.selected { 323 | border-color: #CE4646; 324 | } 325 | 326 | .clear-completed, 327 | html .clear-completed:active { 328 | float: right; 329 | position: relative; 330 | line-height: 19px; 331 | text-decoration: none; 332 | cursor: pointer; 333 | } 334 | 335 | .clear-completed:hover { 336 | text-decoration: underline; 337 | } 338 | 339 | .info { 340 | margin: 65px auto 0; 341 | color: #4d4d4d; 342 | font-size: 11px; 343 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 344 | text-align: center; 345 | } 346 | 347 | .info p { 348 | line-height: 1; 349 | } 350 | 351 | .info a { 352 | color: inherit; 353 | text-decoration: none; 354 | font-weight: 400; 355 | } 356 | 357 | .info a:hover { 358 | text-decoration: underline; 359 | } 360 | 361 | /* 362 | Hack to remove background from Mobile Safari. 363 | Can't use it globally since it destroys checkboxes in Firefox 364 | */ 365 | @media screen and (-webkit-min-device-pixel-ratio:0) { 366 | .toggle-all, 367 | .todo-list li .toggle { 368 | background: none; 369 | } 370 | 371 | .todo-list li .toggle { 372 | height: 40px; 373 | } 374 | } 375 | 376 | @media (max-width: 430px) { 377 | .footer { 378 | height: 50px; 379 | } 380 | 381 | .filters { 382 | bottom: 10px; 383 | } 384 | } 385 | 386 | :focus, 387 | .toggle:focus + label, 388 | .toggle-all:focus + label { 389 | box-shadow: 0 0 2px 2px #CF7D7D; 390 | outline: 0; 391 | } 392 | -------------------------------------------------------------------------------- /public/css/login.css: -------------------------------------------------------------------------------- 1 | .prompt { 2 | max-width: 400px; 3 | margin: 50px auto; 4 | padding: 25px; 5 | background: #fff; 6 | border: 1px solid #e6e6e6; 7 | border-radius: 8px; 8 | } 9 | 10 | button { 11 | display: block; 12 | padding: 10px; 13 | width: 100%; 14 | border-radius: 3px; 15 | background: #d83f45; 16 | font-size: 14px; 17 | font-weight: 700; 18 | color: white; 19 | cursor: pointer; 20 | } 21 | 22 | a.button { 23 | box-sizing: border-box; 24 | display: block; 25 | padding: 10px; 26 | width: 100%; 27 | border-radius: 3px; 28 | background: #000; 29 | font-size: 14px; 30 | font-weight: 700; 31 | text-align: center; 32 | text-decoration: none; 33 | color: white; 34 | } 35 | 36 | a.google { 37 | background: #4787ed; 38 | } 39 | 40 | button:hover { 41 | background-color: #c83f45; 42 | } 43 | 44 | h1 { 45 | margin: 0 0 20px 0; 46 | padding: 0 0 5px 0; 47 | font-size: 24px; 48 | font-weight: 500; 49 | } 50 | 51 | h3 { 52 | margin-top: 0; 53 | font-size: 24px; 54 | font-weight: 300; 55 | text-align: center; 56 | color: #b83f45; 57 | } 58 | 59 | form section { 60 | margin: 0 0 20px 0; 61 | position: relative; /* for password toggle positioning */ 62 | } 63 | 64 | label { 65 | display: block; 66 | margin: 0 0 3px 0; 67 | font-size: 14px; 68 | font-weight: 500; 69 | } 70 | 71 | input { 72 | box-sizing: border-box; 73 | width: 100%; 74 | padding: 10px; 75 | font-size: 14px; 76 | border: 1px solid #d9d9d9; 77 | border-radius: 5px; 78 | } 79 | 80 | input[type=email]:not(:focus):invalid, 81 | input[type=password]:not(:focus):invalid { 82 | color: red; 83 | outline-color: red; 84 | } 85 | 86 | hr { 87 | border-top: 1px solid #d9d9d9; 88 | border-bottom: none; 89 | } 90 | 91 | p.help { 92 | text-align: center; 93 | font-weight: 400; 94 | } 95 | 96 | /* background image by Cole Bemis */ 97 | .messages p { 98 | font-size: 14px; 99 | font-weight: 400; 100 | line-height: 1.3; 101 | color: #d83f45; 102 | padding-left: 20px; 103 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23d83f45' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-alert-circle'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); 104 | background-repeat: no-repeat; 105 | background-position: center left; 106 | } 107 | -------------------------------------------------------------------------------- /routes/auth.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var passport = require('passport'); 3 | var GoogleStrategy = require('passport-google-oauth20'); 4 | var db = require('../db'); 5 | 6 | 7 | // Configure the Facebook strategy for use by Passport. 8 | // 9 | // OAuth 2.0-based strategies require a `verify` function which receives the 10 | // credential (`accessToken`) for accessing the Facebook API on the user's 11 | // behalf, along with the user's profile. The function must invoke `cb` 12 | // with a user object, which will be set at `req.user` in route handlers after 13 | // authentication. 14 | passport.use(new GoogleStrategy({ 15 | clientID: process.env['GOOGLE_CLIENT_ID'], 16 | clientSecret: process.env['GOOGLE_CLIENT_SECRET'], 17 | callbackURL: '/oauth2/redirect/google', 18 | scope: [ 'profile' ], 19 | state: true 20 | }, 21 | function(accessToken, refreshToken, profile, cb) { 22 | db.get('SELECT * FROM federated_credentials WHERE provider = ? AND subject = ?', [ 23 | 'https://accounts.google.com', 24 | profile.id 25 | ], function(err, row) { 26 | if (err) { return cb(err); } 27 | if (!row) { 28 | db.run('INSERT INTO users (name) VALUES (?)', [ 29 | profile.displayName 30 | ], function(err) { 31 | if (err) { return cb(err); } 32 | var id = this.lastID; 33 | db.run('INSERT INTO federated_credentials (user_id, provider, subject) VALUES (?, ?, ?)', [ 34 | id, 35 | 'https://accounts.google.com', 36 | profile.id 37 | ], function(err) { 38 | if (err) { return cb(err); } 39 | var user = { 40 | id: id, 41 | name: profile.displayName 42 | }; 43 | return cb(null, user); 44 | }); 45 | }); 46 | } else { 47 | db.get('SELECT rowid AS id, * FROM users WHERE rowid = ?', [ row.user_id ], function(err, row) { 48 | if (err) { return cb(err); } 49 | if (!row) { return cb(null, false); } 50 | return cb(null, row); 51 | }); 52 | } 53 | }); 54 | })); 55 | 56 | // Configure Passport authenticated session persistence. 57 | // 58 | // In order to restore authentication state across HTTP requests, Passport needs 59 | // to serialize users into and deserialize users out of the session. In a 60 | // production-quality application, this would typically be as simple as 61 | // supplying the user ID when serializing, and querying the user record by ID 62 | // from the database when deserializing. However, due to the fact that this 63 | // example does not have a database, the complete Facebook profile is serialized 64 | // and deserialized. 65 | passport.serializeUser(function(user, cb) { 66 | process.nextTick(function() { 67 | cb(null, { id: user.id, username: user.username, name: user.name }); 68 | }); 69 | }); 70 | 71 | passport.deserializeUser(function(user, cb) { 72 | process.nextTick(function() { 73 | return cb(null, user); 74 | }); 75 | }); 76 | 77 | 78 | var router = express.Router(); 79 | 80 | /* GET /login 81 | * 82 | * This route prompts the user to log in. 83 | * 84 | * The 'login' view renders an HTML page, which contain a button prompting the 85 | * user to sign in with Google. When the user clicks this button, a request 86 | * will be sent to the `GET /login/federated/accounts.google.com` route. 87 | */ 88 | router.get('/login', function(req, res, next) { 89 | res.render('login'); 90 | }); 91 | 92 | /* GET /login/federated/accounts.google.com 93 | * 94 | * This route redirects the user to Google, where they will authenticate. 95 | * 96 | * Signing in with Google is implemented using OAuth 2.0. This route initiates 97 | * an OAuth 2.0 flow by redirecting the user to Google's identity server at 98 | * 'https://accounts.google.com'. Once there, Google will authenticate the user 99 | * and obtain their consent to release identity information to this app. 100 | * 101 | * Once Google has completed their interaction with the user, the user will be 102 | * redirected back to the app at `GET /oauth2/redirect/accounts.google.com`. 103 | */ 104 | router.get('/login/federated/google', passport.authenticate('google')); 105 | 106 | /* 107 | This route completes the authentication sequence when Google redirects the 108 | user back to the application. When a new user signs in, a user account is 109 | automatically created and their Google account is linked. When an existing 110 | user returns, they are signed in to their linked account. 111 | */ 112 | router.get('/oauth2/redirect/google', passport.authenticate('google', { 113 | successReturnToOrRedirect: '/', 114 | failureRedirect: '/login' 115 | })); 116 | 117 | /* POST /logout 118 | * 119 | * This route logs the user out. 120 | */ 121 | router.post('/logout', function(req, res, next) { 122 | req.logout(); 123 | res.redirect('/'); 124 | }); 125 | 126 | module.exports = router; 127 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var ensureLogIn = require('connect-ensure-login').ensureLoggedIn; 3 | var db = require('../db'); 4 | 5 | var ensureLoggedIn = ensureLogIn(); 6 | 7 | function fetchTodos(req, res, next) { 8 | db.all('SELECT rowid AS id, * FROM todos WHERE owner_id = ?', [ 9 | req.user.id 10 | ], function(err, rows) { 11 | if (err) { return next(err); } 12 | 13 | var todos = rows.map(function(row) { 14 | return { 15 | id: row.id, 16 | title: row.title, 17 | completed: row.completed == 1 ? true : false, 18 | url: '/' + row.id 19 | } 20 | }); 21 | res.locals.todos = todos; 22 | res.locals.activeCount = todos.filter(function(todo) { return !todo.completed; }).length; 23 | res.locals.completedCount = todos.length - res.locals.activeCount; 24 | next(); 25 | }); 26 | } 27 | 28 | var router = express.Router(); 29 | 30 | /* GET home page. */ 31 | router.get('/', function(req, res, next) { 32 | if (!req.user) { return res.render('home'); } 33 | next(); 34 | }, fetchTodos, function(req, res, next) { 35 | res.locals.filter = null; 36 | res.render('index', { user: req.user }); 37 | }); 38 | 39 | router.get('/active', ensureLoggedIn, fetchTodos, function(req, res, next) { 40 | res.locals.todos = res.locals.todos.filter(function(todo) { return !todo.completed; }); 41 | res.locals.filter = 'active'; 42 | res.render('index', { user: req.user }); 43 | }); 44 | 45 | router.get('/completed', ensureLoggedIn, fetchTodos, function(req, res, next) { 46 | res.locals.todos = res.locals.todos.filter(function(todo) { return todo.completed; }); 47 | res.locals.filter = 'completed'; 48 | res.render('index', { user: req.user }); 49 | }); 50 | 51 | router.post('/', ensureLoggedIn, function(req, res, next) { 52 | req.body.title = req.body.title.trim(); 53 | next(); 54 | }, function(req, res, next) { 55 | if (req.body.title !== '') { return next(); } 56 | return res.redirect('/' + (req.body.filter || '')); 57 | }, function(req, res, next) { 58 | db.run('INSERT INTO todos (owner_id, title, completed) VALUES (?, ?, ?)', [ 59 | req.user.id, 60 | req.body.title, 61 | req.body.completed == true ? 1 : null 62 | ], function(err) { 63 | if (err) { return next(err); } 64 | return res.redirect('/' + (req.body.filter || '')); 65 | }); 66 | }); 67 | 68 | router.post('/:id(\\d+)', ensureLoggedIn, function(req, res, next) { 69 | req.body.title = req.body.title.trim(); 70 | next(); 71 | }, function(req, res, next) { 72 | if (req.body.title !== '') { return next(); } 73 | db.run('DELETE FROM todos WHERE rowid = ? AND owner_id = ?', [ 74 | req.params.id, 75 | req.user.id 76 | ], function(err) { 77 | if (err) { return next(err); } 78 | return res.redirect('/' + (req.body.filter || '')); 79 | }); 80 | }, function(req, res, next) { 81 | db.run('UPDATE todos SET title = ?, completed = ? WHERE rowid = ? AND owner_id = ?', [ 82 | req.body.title, 83 | req.body.completed !== undefined ? 1 : null, 84 | req.params.id, 85 | req.user.id 86 | ], function(err) { 87 | if (err) { return next(err); } 88 | return res.redirect('/' + (req.body.filter || '')); 89 | }); 90 | }); 91 | 92 | router.post('/:id(\\d+)/delete', ensureLoggedIn, function(req, res, next) { 93 | db.run('DELETE FROM todos WHERE rowid = ? AND owner_id = ?', [ 94 | req.params.id, 95 | req.user.id 96 | ], function(err) { 97 | if (err) { return next(err); } 98 | return res.redirect('/' + (req.body.filter || '')); 99 | }); 100 | }); 101 | 102 | router.post('/toggle-all', ensureLoggedIn, function(req, res, next) { 103 | db.run('UPDATE todos SET completed = ? WHERE owner_id = ?', [ 104 | req.body.completed !== undefined ? 1 : null, 105 | req.user.id 106 | ], function(err) { 107 | if (err) { return next(err); } 108 | return res.redirect('/' + (req.body.filter || '')); 109 | }); 110 | }); 111 | 112 | router.post('/clear-completed', ensureLoggedIn, function(req, res, next) { 113 | db.run('DELETE FROM todos WHERE owner_id = ? AND completed = ?', [ 114 | req.user.id, 115 | 1 116 | ], function(err) { 117 | if (err) { return next(err); } 118 | return res.redirect('/' + (req.body.filter || '')); 119 | }); 120 | }); 121 | 122 | module.exports = router; 123 | -------------------------------------------------------------------------------- /views/error.ejs: -------------------------------------------------------------------------------- 1 |

<%= message %>

2 |

<%= error.status %>

3 |
<%= error.stack %>
4 | -------------------------------------------------------------------------------- /views/home.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Express • TodoMVC 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

todos

15 |
16 |
17 |

todos helps you get things done

18 | Sign in 19 |
20 |
21 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Express • TodoMVC 7 | 8 | 9 | 10 | 11 | 12 |
13 | 24 |
25 |

todos

26 |
27 | 28 | <% if (filter) { %> 29 | 30 | <% } %> 31 | 32 |
33 |
34 | <% if (activeCount + completedCount > 0) { %> 35 |
36 |
37 | onchange="this.form.submit();"> 38 | 39 | 40 |
41 |
    42 | <% todos.forEach(function(todo) { %> 43 |
  • > 44 |
    45 |
    46 | onchange="this.form.submit();"> 47 | 48 | 49 |
    50 | 51 | <% if (filter) { %> 52 | 53 | <% } %> 54 | 55 |
    56 |
    57 | <% if (filter) { %> 58 | 59 | <% } %> 60 | 61 |
    62 |
  • 63 | <% }); %> 64 |
65 |
66 | <% } %> 67 | <% if (activeCount + completedCount > 0) { %> 68 |
69 | <%= activeCount %> <%= pluralize('item', activeCount) %> left 70 | 81 | <% if (completedCount > 0) { %> 82 |
83 | 84 | <% if (filter) { %> 85 | 86 | <% } %> 87 | 88 |
89 | <% } %> 90 |
91 | <% } %> 92 |
93 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /views/login.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Express • TodoMVC 7 | 8 | 9 | 10 | 11 | 12 |
13 |

todos

14 |

Sign in

15 | <% if (hasMessages) { %> 16 |
17 | <% messages.forEach(function(message) { %> 18 |

<%= message %>

19 | <% }); %> 20 |
21 | <% } %> 22 | Sign in with Google 23 |
24 | 29 | 30 | 31 | --------------------------------------------------------------------------------