├── .gitignore ├── README.md ├── functions ├── auth-callback.js ├── auth-start.js ├── package.json └── utils │ ├── auth.js │ └── netlify-api.js ├── netlify.toml ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.css ├── App.js ├── assets │ └── netlify-login-button.svg ├── components │ └── ForkMe │ │ ├── ForkMe.css │ │ └── index.js ├── index.css ├── index.js └── utils │ ├── auth.js │ └── sort.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 | package-lock.json 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | .netlify 15 | 16 | 17 | # misc 18 | misc.md 19 | src/stub.js 20 | .DS_Store 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Netlify OAuth 2 | 3 | deploy to netlify 4 | 5 | 6 |

7 | 8 | Example of how to use Netlify OAuth Applications 9 | 10 | ## Use cases 11 | 12 | Using Netlify OAuth you can create custom experiences using the [Netlify Open API](https://open-api.netlify.com/#/default). 13 | 14 | **Here are some use cases:** 15 | 16 | - Building a custom Netlify admin UI 17 | - Building Netlify Desktop Applications 18 | - Making an App that user's existing Netlify sites 19 | - Manage Netlify sites, new deployments, & other things from inside your third party application 20 | 21 | ## Video 22 | 23 | 24 | 25 | ## How it works 26 | 27 | ![Netlify OAuth + Functions](https://user-images.githubusercontent.com/532272/54178445-106c0600-4453-11e9-998f-564a521dfc6b.png) 28 | 29 | ## Setup 30 | 31 | 1. **Create and Deploy a new Netlify site** 32 | 33 | You can use an [this repo](https://app.netlify.com/start/deploy?repository=https://github.com/netlify-labs/oauth-example) 34 | 35 | 2. **[Create OAuth application](https://app.netlify.com/account/applications)** 36 | 37 | Create your OAuth application in the Netlify admin UI. 38 | 39 | Add in your callback URL. This can be changed later. 40 | 41 | ![image](https://user-images.githubusercontent.com/532272/53382433-3066da00-3929-11e9-978a-74d802c212db.png) 42 | 43 | 3. **After creating your OAuth app, Click on show credentials** 44 | 45 | Save these credentials for the next step 46 | 47 | ![image](https://user-images.githubusercontent.com/532272/53382437-3957ab80-3929-11e9-9cbf-b812cd04c2c7.png) 48 | 49 | 4. **Take your OAuth credentials and add them to your OAuth app site** 50 | 51 | Set `NETLIFY_OAUTH_CLIENT_ID` and `NETLIFY_OAUTH_CLIENT_SECRET` environment variables in your site 52 | 53 | ![image](https://user-images.githubusercontent.com/532272/53382472-53918980-3929-11e9-9d24-598247b5f2c6.png) 54 | 55 | 5. **Then trigger a new deploy** 56 | 57 | ![image](https://user-images.githubusercontent.com/532272/53382490-6015e200-3929-11e9-9f6b-92be59d78e59.png) 58 | 59 | 60 | 6. **Visit your site and verify the OAuth flow is working** 61 | 62 | -------------------------------------------------------------------------------- /functions/auth-callback.js: -------------------------------------------------------------------------------- 1 | const querystring = require('querystring') 2 | const { config, oauth } = require('./utils/auth') 3 | const { getUser } = require('./utils/netlify-api') 4 | 5 | /* Function to handle netlify auth callback */ 6 | exports.handler = async (event, context) => { 7 | // Exit early 8 | if (!event.queryStringParameters) { 9 | return { 10 | statusCode: 401, 11 | body: JSON.stringify({ 12 | error: 'Not authorized', 13 | }) 14 | } 15 | } 16 | 17 | /* Grant the grant code */ 18 | const code = event.queryStringParameters.code 19 | /* state helps mitigate CSRF attacks & Restore the previous state of your app */ 20 | const state = querystring.parse(event.queryStringParameters.state) 21 | 22 | try { 23 | /* Take the grant code and exchange for an accessToken */ 24 | const authorizationToken = await oauth.authorizationCode.getToken({ 25 | code: code, 26 | redirect_uri: config.redirect_uri, 27 | client_id: config.clientId, 28 | client_secret: config.clientSecret 29 | }) 30 | 31 | const authResult = oauth.accessToken.create(authorizationToken) 32 | 33 | const token = authResult.token.access_token 34 | 35 | const user = await getUser(token) 36 | 37 | // return { 38 | // statusCode: 200, 39 | // body: JSON.stringify({ 40 | // user: user, 41 | // authResult: authResult, 42 | // state: state, 43 | // encode: Buffer.from(token, 'binary').toString('base64') 44 | // }) 45 | // } 46 | 47 | const encodedUserData = querystring.stringify({ 48 | email: user.email || "NA", 49 | full_name: user.full_name || "NA", 50 | avatar: user.avatar_url || "NA" 51 | }) 52 | 53 | const URI = `${state.url}#${encodedUserData}&csrf=${state.csrf}&token=${Buffer.from(token, 'binary').toString('base64')}` 54 | console.log('URI', URI) 55 | /* Redirect user to authorizationURI */ 56 | return { 57 | statusCode: 302, 58 | headers: { 59 | Location: URI, 60 | 'Cache-Control': 'no-cache' // Disable caching of this response 61 | }, 62 | body: '' // return body for local dev 63 | } 64 | 65 | 66 | } catch (e) { 67 | console.log('Access Token Error', e.message) 68 | console.log(e) 69 | return { 70 | statusCode: e.statusCode || 500, 71 | body: JSON.stringify({ 72 | error: e.message, 73 | }) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /functions/auth-start.js: -------------------------------------------------------------------------------- 1 | const { config, oauth } = require('./utils/auth') 2 | 3 | /* Do initial auth redirect */ 4 | exports.handler = async (event, context) => { 5 | 6 | if (!event.queryStringParameters) { 7 | return { 8 | statusCode: 401, 9 | body: JSON.stringify({ 10 | error: 'No token found', 11 | }) 12 | } 13 | } 14 | 15 | const csrfToken = event.queryStringParameters.csrf 16 | const redirectUrl = event.queryStringParameters.url 17 | 18 | /* Generate authorizationURI */ 19 | const authorizationURI = oauth.authorizationCode.authorizeURL({ 20 | redirect_uri: config.redirect_uri, 21 | /* Specify how your app needs to access the user’s account. */ 22 | scope: '', 23 | /* State helps mitigate CSRF attacks & Restore the previous state of your app */ 24 | state: `url=${redirectUrl}&csrf=${csrfToken}`, 25 | }) 26 | 27 | /* Redirect user to authorizationURI */ 28 | return { 29 | statusCode: 302, 30 | headers: { 31 | Location: authorizationURI, 32 | 'Cache-Control': 'no-cache' // Disable caching of this response 33 | }, 34 | body: '' // return body for local dev 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend-functions", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "netlify": "^2.3.1", 7 | "node-fetch": "^2.3.0", 8 | "simple-oauth2": "^2.2.1" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /functions/utils/auth.js: -------------------------------------------------------------------------------- 1 | const simpleOauth = require('simple-oauth2') 2 | 3 | const SITE_URL = process.env.URL || 'http://localhost:3000' 4 | 5 | /* Auth values */ 6 | const TOKEN_HOST = 'https://api.netlify.com' 7 | const TOKEN_URL = 'https://api.netlify.com/oauth/token' 8 | const USER_PROFILE_URL = 'https://api.netlify.com/api/v1/user' 9 | const AUTHORIZATION_URL = 'https://app.netlify.com/authorize' 10 | const REDIRECT_URL = `${SITE_URL}/.netlify/functions/auth-callback` 11 | 12 | /* Env key name */ 13 | const clientIdKey = 'NETLIFY_OAUTH_CLIENT_ID' 14 | const clientSecretKey = 'NETLIFY_OAUTH_CLIENT_SECRET' 15 | 16 | const config = { 17 | /* values set in terminal session or in netlify environment variables */ 18 | clientId: process.env[clientIdKey], 19 | clientSecret: process.env[clientSecretKey], 20 | /* OAuth API endpoints */ 21 | tokenHost: TOKEN_HOST, 22 | authorizePath: AUTHORIZATION_URL, 23 | tokenPath: TOKEN_URL, 24 | profilePath: USER_PROFILE_URL, 25 | /* redirect_uri is the callback url after successful signin */ 26 | redirect_uri: REDIRECT_URL, 27 | } 28 | 29 | function authInstance(credentials) { 30 | if (!credentials.client.id) { 31 | throw new Error(`MISSING REQUIRED ENV VARS. Please set ${clientIdKey}`) 32 | } 33 | if (!credentials.client.secret) { 34 | throw new Error(`MISSING REQUIRED ENV VARS. Please set ${clientSecretKey}`) 35 | } 36 | return simpleOauth.create(credentials) 37 | } 38 | 39 | module.exports = { 40 | /* Export config for functions */ 41 | config: config, 42 | /* Create oauth2 instance to use in our functions */ 43 | oauth: authInstance({ 44 | client: { 45 | id: config.clientId, 46 | secret: config.clientSecret 47 | }, 48 | auth: { 49 | tokenHost: config.tokenHost, 50 | tokenPath: config.tokenPath, 51 | authorizePath: config.authorizePath 52 | } 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /functions/utils/netlify-api.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch') 2 | 3 | async function getUser(token) { 4 | const url = `https://api.netlify.com/api/v1/user/` 5 | const response = await fetch(url, { 6 | method: 'GET', 7 | headers: { 8 | 'Content-Type': 'application/json', 9 | Authorization: `Bearer ${token}` 10 | } 11 | }) 12 | 13 | const data = await response.json() 14 | 15 | if (response.status === 422) { 16 | throw new Error(`Error ${JSON.stringify(data)}`) 17 | } 18 | 19 | return data 20 | } 21 | 22 | module.exports = { 23 | getUser: getUser 24 | } 25 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "build" 3 | command = "npm run build" 4 | functions = "functions" 5 | 6 | [template.environment] 7 | NETLIFY_OAUTH_CLIENT_ID = "Your Netlify OAuth Client ID" 8 | NETLIFY_OAUTH_CLIENT_SECRET= "Your Netlify OAuth Client Secret" 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oauth-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "netlify": "^2.4.0", 7 | "react": "^16.8.3", 8 | "react-dom": "^16.8.3", 9 | "react-scripts": "2.1.5", 10 | "time-ago": "^0.2.1" 11 | }, 12 | "scripts": { 13 | "start": "react-scripts start", 14 | "build": "npm-run-all --parallel build:**", 15 | "build:app": "react-scripts build", 16 | "build:functions": "cd functions && npm install", 17 | "deploy": "npm run build && netlify deploy -p" 18 | }, 19 | "eslintConfig": { 20 | "extends": "react-app" 21 | }, 22 | "browserslist": [ 23 | ">0.2%", 24 | "not dead", 25 | "not ie <= 11", 26 | "not op_mini all" 27 | ], 28 | "devDependencies": { 29 | "npm-run-all": "^4.1.5" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netlify-labs/oauth-example/a7987d8417c908be7b3d04c4c5bf4f5d3a378ada/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 25 | React App 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 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 | .app { 2 | margin: 20px; 3 | margin-top: 30px; 4 | margin-left: 40px; 5 | } 6 | .app h1 { 7 | margin-bottom: 30px; 8 | } 9 | .title-inner { 10 | display: flex; 11 | align-items: center; 12 | } 13 | .title-inner button { 14 | margin-left: 20px; 15 | } 16 | 17 | button { 18 | cursor: pointer; 19 | background-color: #424242; 20 | font-family: inherit; 21 | color: #fff; 22 | display: inline-flex; 23 | align-items: center; 24 | justify-content: center; 25 | font-size: 15px; 26 | border: 1px solid #e9ebeb; 27 | border-bottom-color: #e1e3e3; 28 | border-radius: 4px; 29 | background-color: #fff; 30 | color: rgba(14,30,37,.87); 31 | box-shadow: 0 2px 4px 0 rgba(14,30,37,.12); 32 | transition: all .2s ease; 33 | transition-property: background-color,color,border,box-shadow; 34 | outline: 0; 35 | font-weight: 500; 36 | background: #00ad9f; 37 | color: #fff; 38 | border-color: transparent; 39 | } 40 | .primary-button { 41 | padding: 13px 18px; 42 | } 43 | .primary-button:hover { 44 | background: #00c2b2; 45 | } 46 | 47 | .search { 48 | border-radius: 2px; 49 | font-size: 16px; 50 | padding: 11px 15px; 51 | min-width: 300px; 52 | display: inline-block; 53 | box-shadow: 0 0 0 2px rgba(120,130,152,.25); 54 | border: none; 55 | outline: none; 56 | transition: all .3s ease; 57 | margin: 20px 0; 58 | } 59 | .search:active, .search:focus, .search:hover { 60 | box-shadow: 0 0 0 2px #00ad9f; 61 | } 62 | 63 | .header { 64 | cursor: pointer; 65 | font-weight: bold; 66 | font-size: 16px !important; 67 | } 68 | .item { 69 | text-align: center; 70 | } 71 | .site-wrapper, .site-wrapper-header { 72 | display: flex; 73 | align-items: center; 74 | padding: 10px 0; 75 | padding-left: 20px; 76 | font-size: 14px; 77 | } 78 | .site-wrapper-header { 79 | padding-left: 15px; 80 | } 81 | .site-wrapper:hover { 82 | background: rgba(14,30,37,.05); 83 | } 84 | .site-screenshot, .site-screenshot-header { 85 | height: 64px; 86 | margin: 0 24px 0 0; 87 | min-width: 102.4px; 88 | position: relative; 89 | } 90 | .site-screenshot-header { 91 | height: 30px; 92 | } 93 | .site-screenshot:before { 94 | background: #dadcdd; 95 | bottom: 0; 96 | content: " "; 97 | left: 0; 98 | position: absolute; 99 | right: 0; 100 | top: 0; 101 | } 102 | .site-screenshot a { 103 | display: block; 104 | position: relative; 105 | z-index: 9; 106 | height: 100%; 107 | } 108 | .site-screenshot img { 109 | position: relative; 110 | width: 100%; 111 | width: 102.4px; 112 | border: none; 113 | } 114 | .site-screenshot img[alt]:after { 115 | display: block; 116 | position: absolute; 117 | top: 0; 118 | left: 0; 119 | width: 100%; 120 | height: 100%; 121 | background-color: #dadcdd; 122 | font-weight: 300; 123 | line-height: 2; 124 | text-align: center; 125 | content: ''; 126 | } 127 | .site-info { 128 | min-width: 370px; 129 | max-width: 370px; 130 | } 131 | .site-info h2 { 132 | margin: 0px; 133 | margin-bottom: 5px; 134 | } 135 | .site-info a { 136 | text-decoration: none; 137 | } 138 | .site-meta a { 139 | font-size: 14px; 140 | color: rgba(14,30,37,.8); 141 | } 142 | .site-publish-time { 143 | min-width: 150px; 144 | } 145 | .site-create-time { 146 | min-width: 130px; 147 | } 148 | .site-team { 149 | min-width: 200px; 150 | } 151 | .site-functions { 152 | min-width: 100px; 153 | } 154 | .site-repo-link { 155 | font-size: 12px; 156 | } 157 | 158 | @media (max-width: 1400px) { 159 | .site-repo-link { 160 | display: none; 161 | } 162 | } 163 | 164 | @media (max-width: 1150px) { 165 | .site-functions { 166 | display: none; 167 | } 168 | } 169 | 170 | @media (max-width: 1000px) { 171 | .site-team { 172 | display: none; 173 | } 174 | } 175 | 176 | @media (max-width: 830px) { 177 | .site-create-time { 178 | display: none; 179 | } 180 | .search { 181 | appearance: none; 182 | border: 1px solid rgba(120,130,152,.25); 183 | font-size: 16px; 184 | } 185 | .site-screenshot img[alt]:after { 186 | content: none; 187 | } 188 | .site-screenshot, .site-screenshot img { 189 | background: #dadcdd; 190 | min-height: 50px; 191 | min-width: 78px; 192 | width: 78px; 193 | height: 50px; 194 | margin: 0 20px 0 0; 195 | } 196 | .site-info { 197 | min-width: 350px; 198 | max-width: 350px; 199 | } 200 | } 201 | 202 | @media (max-width: 720px) { 203 | .app { 204 | margin: 15px; 205 | margin-top: 20px; 206 | } 207 | .app h1 { 208 | font-size: 22px; 209 | margin-bottom: inherit; 210 | } 211 | .title-inner { 212 | width: 100%; 213 | display: flex; 214 | justify-content: space-between; 215 | } 216 | .title-inner button { 217 | margin-right: 40px; 218 | } 219 | .contents { 220 | overflow: hidden; 221 | } 222 | .contents input { 223 | margin-left: 3px; 224 | } 225 | .site-wrapper, .site-wrapper-header { 226 | padding-left: 10px; 227 | } 228 | .site-publish-time { 229 | display: none; 230 | } 231 | .site-info h2 { 232 | font-size: 18px; 233 | } 234 | .site-meta a { 235 | font-size: 13px; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import NetlifyAPI from 'netlify' 3 | import timeAgo from 'time-ago' 4 | import { csrfToken, parseHash, removeHash } from './utils/auth' 5 | import { 6 | sortByDate, 7 | sortByPublishDate, 8 | sortByName, 9 | sortByFunctions, 10 | sortByRepo, 11 | matchText 12 | } from './utils/sort' 13 | import ForkMe from './components/ForkMe' 14 | import loginButton from './assets/netlify-login-button.svg' 15 | import './App.css' 16 | 17 | // import stub from './stub' 18 | 19 | export default class App extends Component { 20 | constructor(props, context) { 21 | super(props, context) 22 | console.log('window.location.hash', window.location.hash) 23 | const response = parseHash(window.location.hash) 24 | /* Clear hash */ 25 | removeHash() 26 | 27 | /* Protect against csrf (cross site request forgery https://bit.ly/1V1AvZD) */ 28 | if (response.token && !localStorage.getItem(response.csrf)) { 29 | alert('Token invalid. Please try to login again') 30 | return 31 | } 32 | 33 | /* Clean up csrfToken */ 34 | localStorage.removeItem(response.csrf) 35 | 36 | /* Set initial app state */ 37 | this.state = { 38 | user: response, 39 | sites: [], 40 | filterText: '', 41 | loading: false, 42 | sortBy: 'published_at', 43 | sortOrder: 'desc' 44 | } 45 | } 46 | async componentDidMount() { 47 | const { user } = this.state 48 | if (!user.token) return 49 | 50 | /* Set request loading state */ 51 | this.setState({ 52 | loading: true 53 | }) 54 | 55 | /* Fetch sites from netlify API */ 56 | const client = new NetlifyAPI(window.atob(user.token)) 57 | const sites = await client.listSites({ 58 | filter: 'all' 59 | }) 60 | 61 | /* Set sites and turn off loading state */ 62 | this.setState({ 63 | sites: sites, 64 | loading: false 65 | }) 66 | } 67 | handleAuth = e => { 68 | e.preventDefault() 69 | const state = csrfToken() 70 | const { location, localStorage } = window 71 | /* Set csrf token */ 72 | localStorage.setItem(state, 'true') 73 | /* Do redirect */ 74 | const redirectTo = `${location.origin}${location.pathname}` 75 | window.location.href = `/.netlify/functions/auth-start?url=${redirectTo}&csrf=${state}` 76 | } 77 | handleLogout = e => { 78 | e.preventDefault() 79 | window.location.href = `/` 80 | } 81 | handleFilterInput = e => { 82 | this.setState({ 83 | filterText: e.target.value 84 | }) 85 | } 86 | handleSort = e => { 87 | const { sortOrder } = this.state 88 | if (e.target && e.target.dataset) { 89 | this.setState({ 90 | sortBy: e.target.dataset.sort, 91 | // invert sort order 92 | sortOrder: sortOrder === 'desc' ? 'asc' : 'desc' 93 | }) 94 | } 95 | } 96 | renderSiteList = () => { 97 | const { sites, filterText, loading, sortBy, sortOrder } = this.state 98 | 99 | if (loading) { 100 | return
Loading sites...
101 | } 102 | 103 | let order 104 | if (sortBy === 'published_at') { 105 | order = sortByPublishDate(sortOrder) 106 | } else if (sortBy === 'name' || sortBy === 'account_name') { 107 | order = sortByName(sortBy, sortOrder) 108 | } else if (sortBy === 'updated_at' || sortBy === 'created_at') { 109 | order = sortByDate(sortBy, sortOrder) 110 | } else if (sortBy === 'functions') { 111 | order = sortByFunctions(sortOrder) 112 | } else if (sortBy === 'repo') { 113 | order = sortByRepo(sortOrder) 114 | } 115 | 116 | const sortedSites = sites.sort(order) 117 | 118 | let matchingSites = sortedSites.filter(site => { 119 | // No search query. Show all 120 | if (!filterText) { 121 | return true 122 | } 123 | 124 | const { name, site_id, ssl_url, build_settings } = site 125 | if ( 126 | matchText(filterText, name) || 127 | matchText(filterText, site_id) || 128 | matchText(filterText, ssl_url) 129 | ) { 130 | return true 131 | } 132 | 133 | // Matches repo url 134 | if ( 135 | build_settings && 136 | build_settings.repo_url && 137 | matchText(filterText, build_settings.repo_url) 138 | ) { 139 | return true 140 | } 141 | 142 | // no match! 143 | return false 144 | }) 145 | .map((site, i) => { 146 | const { 147 | name, 148 | account_name, 149 | account_slug, 150 | admin_url, 151 | ssl_url, 152 | screenshot_url, 153 | created_at 154 | } = site 155 | const published_deploy = site.published_deploy || {} 156 | const functions = published_deploy.available_functions || [] 157 | const functionsNames = functions.map(func => func.n).join(', ') 158 | const build_settings = site.build_settings || {} 159 | const { repo_url } = build_settings 160 | const time = published_deploy.published_at ? timeAgo.ago(new Date(published_deploy.published_at).getTime()) : 'NA' 161 | const createdAt = created_at ? timeAgo.ago(new Date(created_at).getTime()) : 'NA' 162 | return ( 163 |
164 |
165 | 166 | 167 | 168 |
169 |
170 |

171 | 172 | {name} 173 | 174 |

175 |
176 | 177 | {ssl_url} 178 | 179 |
180 |
181 |
182 | 187 | {account_name} 188 | 189 |
190 |
{time}
191 |
192 |
193 | 198 | {functions.length} 199 | 200 |
201 |
202 |
{createdAt}
203 |
204 | {repo_url ? ( 205 | 206 | {repo_url.replace(/^https:\/\//, '')} 207 | 208 | ) : ( 209 | '' 210 | )} 211 |
212 |
213 | ) 214 | }) 215 | 216 | if (!matchingSites.length) { 217 | matchingSites = ( 218 |
219 |

220 | No '{filterText}' examples found. Clear your search and try again. 221 |

222 |
223 | ) 224 | } 225 | return matchingSites 226 | } 227 | render() { 228 | const { user } = this.state 229 | 230 | /* Not logged in. Show login button */ 231 | if (user && !user.token) { 232 | return ( 233 |
234 | 235 |

Netlify Site Search

236 | 239 |
240 | ) 241 | } 242 | 243 | /* Show admin UI */ 244 | return ( 245 |
246 | 247 |

248 | 249 | Hi {user.full_name || 'Friend'} 250 | 253 | 254 |

255 |
256 | 261 |
262 |
268 | Site Info 269 |
270 |
275 |
281 | Team 282 |
283 |
289 | Last published 290 |
291 |
297 | Functions 298 |
299 |
305 | Created At 306 |
307 |
313 | Repo 314 |
315 |
316 | {this.renderSiteList()} 317 |
318 |
319 | ) 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /src/assets/netlify-login-button.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ForkMe/ForkMe.css: -------------------------------------------------------------------------------- 1 | .github-corner svg { 2 | height: 80px; 3 | width: 80px; 4 | fill: #151513; 5 | color: #fff; 6 | position: absolute; 7 | top: 0; 8 | border: 0; 9 | right: 0; 10 | } 11 | .github-corner:hover .octo-arm { 12 | animation:octocat-wave 560ms ease-in-out 13 | } 14 | @keyframes octocat-wave { 15 | 0%, 100%{ 16 | transform:rotate(0) 17 | } 18 | 20%, 60%{ 19 | transform:rotate(-25deg) 20 | } 21 | 40%, 80%{ 22 | transform:rotate(10deg) 23 | } 24 | } 25 | @media (max-width: 720px) { 26 | .github-corner svg { 27 | height: 60px; 28 | width: 60px; 29 | } 30 | } 31 | @media (max-width:500px) { 32 | .github-corner:hover .octo-arm { 33 | animation:none 34 | } 35 | .github-corner .octo-arm { 36 | animation:octocat-wave 560ms ease-in-out 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/components/ForkMe/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './ForkMe.css' 3 | 4 | const ForkMe = ({ url }) => { 5 | return ( 6 | 7 | 12 | 13 | ) 14 | } 15 | 16 | export default ForkMe 17 | -------------------------------------------------------------------------------- /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", monospace; 13 | } 14 | 15 | a { 16 | text-decoration: none; 17 | color: #00ad9f; 18 | } 19 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import App from './App' 5 | 6 | ReactDOM.render(, document.getElementById('root')) 7 | -------------------------------------------------------------------------------- /src/utils/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate uuid 3 | * @return {String} - uuidv4 4 | */ 5 | export function csrfToken() { 6 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 7 | var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8) // eslint-disable-line 8 | return v.toString(16) 9 | }) 10 | } 11 | 12 | /** 13 | * Parse url hash 14 | * @param {String} hash - Hash of URL 15 | * @return {Object} parsed key value object 16 | */ 17 | export function parseHash(hash) { 18 | if (!hash) return {} 19 | return hash.replace(/^#/, '').split('&').reduce((result, pair) => { 20 | const keyValue = pair.split('=') 21 | result[keyValue[0]] = decode(keyValue[1]) 22 | return result 23 | }, {}) 24 | } 25 | 26 | /** 27 | * Remove hash from URL 28 | * @return {null} 29 | */ 30 | export function removeHash() { 31 | const { history, location } = window 32 | document.location.hash = '' 33 | history.pushState("", document.title, `${location.pathname}${location.search}`) 34 | } 35 | 36 | function decode(s) { 37 | return decodeURIComponent(s).replace(/\+/g, ' ') 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/sort.js: -------------------------------------------------------------------------------- 1 | 2 | export function matchText(search, text) { 3 | if (!text || !search) { 4 | return false 5 | } 6 | return text.toLowerCase().indexOf(search.toLowerCase()) > -1 7 | } 8 | 9 | export function sortByDate(dateType, order) { 10 | return function (a, b) { 11 | const timeA = new Date(a[dateType]).getTime() 12 | const timeB = new Date(b[dateType]).getTime() 13 | if (order === 'asc') { 14 | return timeA - timeB 15 | } 16 | // default 'desc' descending order 17 | return timeB - timeA 18 | } 19 | } 20 | 21 | const oldNumber = '2012-02-25T22:21:57.581Z' 22 | export function sortByPublishDate(order) { 23 | return function (a, b) { 24 | const timeOne = (!a.published_deploy) ? oldNumber : a.published_deploy.published_at 25 | const timeTwo = (!b.published_deploy) ? oldNumber : b.published_deploy.published_at 26 | const timeA = new Date(timeOne).getTime() 27 | const timeB = new Date(timeTwo).getTime() 28 | if (order === 'asc') { 29 | return timeA - timeB 30 | } 31 | // default 'desc' descending order 32 | return timeB - timeA 33 | } 34 | } 35 | 36 | export function sortByName(key, order) { 37 | return function (a, b) { 38 | if (order === 'asc') { 39 | if (a[key] < b[key]) return -1 40 | if (a[key] > b[key]) return 1 41 | } 42 | if (a[key] > b[key]) return -1 43 | if (a[key] < b[key]) return 1 44 | return 0 45 | } 46 | } 47 | 48 | export function sortByFunctions(order) { 49 | return function (a, b) { 50 | const functionsOne = (!a.published_deploy) ? [] : a.published_deploy.available_functions 51 | const functionsTwo = (!b.published_deploy) ? [] : b.published_deploy.available_functions 52 | if (order === 'desc') { 53 | if (functionsOne.length < functionsTwo.length) return -1 54 | if (functionsOne.length > functionsTwo.length) return 1 55 | } 56 | if (functionsOne.length > functionsTwo.length) return -1 57 | if (functionsOne.length < functionsTwo.length) return 1 58 | return 0 59 | } 60 | } 61 | 62 | export function sortByRepo(order) { 63 | return function (a, b) { 64 | const settingsOne = a.build_settings || { repo_url: 'a' } 65 | const settingsTwo = b.build_settings || { repo_url: 'a' } 66 | if (order === 'asc') { 67 | if (settingsOne.repo_url < settingsTwo.repo_url) return -1 68 | if (settingsOne.repo_url > settingsTwo.repo_url) return 1 69 | } 70 | if (settingsOne.repo_url > settingsTwo.repo_url) return -1 71 | if (settingsOne.repo_url < settingsTwo.repo_url) return 1 72 | return 0 73 | } 74 | } 75 | --------------------------------------------------------------------------------