diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..5760be58369 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index 99ed4c4adfd..882f4c09aeb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ coverage/ .aws-config.json awsconfig /dist +/emails/dist /tmp docs/AWS_S3_BUCKET diff --git a/emails/README.md b/emails/README.md new file mode 100644 index 00000000000..61ff6af6ad2 --- /dev/null +++ b/emails/README.md @@ -0,0 +1,8 @@ + +- npm install +- grunt (default task will build new inlines email templates) +- grunt watch (will build on source html or css change) + +assembled email templates will be in dist/ and final +inlined templates will be in ../public/emails/ + diff --git a/emails/assets/css/ink.css b/emails/assets/css/ink.css new file mode 100644 index 00000000000..e65aed19194 --- /dev/null +++ b/emails/assets/css/ink.css @@ -0,0 +1,688 @@ +/********************************************** +* Ink v1.0.5 - Copyright 2013 ZURB Inc * +**********************************************/ + +/* Client-specific Styles & Reset */ + +#outlook a { + padding:0; +} + +body{ + width:100% !important; + min-width: 100%; + -webkit-text-size-adjust:100%; + -ms-text-size-adjust:100%; + margin:0; + padding:0; +} + +.ExternalClass { + width:100%; +} + +.ExternalClass, +.ExternalClass p, +.ExternalClass span, +.ExternalClass font, +.ExternalClass td, +.ExternalClass div { + line-height: 100%; +} + +#backgroundTable { + margin:0; + padding:0; + width:100% !important; + line-height: 100% !important; +} + +img { + outline:none; + text-decoration:none; + -ms-interpolation-mode: bicubic; + width: auto; + max-width: 100%; + float: left; + clear: both; + display: block; +} + +center { + width: 100%; + min-width: 580px; +} + +a img { + border: none; +} + +p { + margin: 0 0 0 10px; +} + +table { + border-spacing: 0; + border-collapse: collapse; +} + +td { + word-break: break-word; + -webkit-hyphens: auto; + -moz-hyphens: auto; + hyphens: auto; + border-collapse: collapse !important; +} + +table, tr, td { + padding: 0; + vertical-align: top; + text-align: left; +} + +hr { + color: #d9d9d9; + background-color: #d9d9d9; + height: 1px; + border: none; +} + +/* Responsive Grid */ + +table.body { + height: 100%; + width: 100%; +} + +table.container { + width: 580px; + margin: 0 auto; + text-align: inherit; +} + +table.row { + padding: 0px; + width: 100%; + position: relative; +} + +table.container table.row { + display: block; +} + +td.wrapper { + padding: 10px 20px 0px 0px; + position: relative; +} + +table.columns, +table.column { + margin: 0 auto; +} + +table.columns td, +table.column td { + padding: 0px 0px 10px; +} + +table.columns td.sub-columns, +table.column td.sub-columns, +table.columns td.sub-column, +table.column td.sub-column { + padding-right: 10px; +} + +td.sub-column, td.sub-columns { + min-width: 0px; +} + +table.row td.last, +table.container td.last { + padding-right: 0px; +} + +table.one { width: 30px; } +table.two { width: 80px; } +table.three { width: 130px; } +table.four { width: 180px; } +table.five { width: 230px; } +table.six { width: 280px; } +table.seven { width: 330px; } +table.eight { width: 380px; } +table.nine { width: 430px; } +table.ten { width: 480px; } +table.eleven { width: 530px; } +table.twelve { width: 580px; } + +table.one center { min-width: 30px; } +table.two center { min-width: 80px; } +table.three center { min-width: 130px; } +table.four center { min-width: 180px; } +table.five center { min-width: 230px; } +table.six center { min-width: 280px; } +table.seven center { min-width: 330px; } +table.eight center { min-width: 380px; } +table.nine center { min-width: 430px; } +table.ten center { min-width: 480px; } +table.eleven center { min-width: 530px; } +table.twelve center { min-width: 580px; } + +table.one .panel center { min-width: 10px; } +table.two .panel center { min-width: 60px; } +table.three .panel center { min-width: 110px; } +table.four .panel center { min-width: 160px; } +table.five .panel center { min-width: 210px; } +table.six .panel center { min-width: 260px; } +table.seven .panel center { min-width: 310px; } +table.eight .panel center { min-width: 360px; } +table.nine .panel center { min-width: 410px; } +table.ten .panel center { min-width: 460px; } +table.eleven .panel center { min-width: 510px; } +table.twelve .panel center { min-width: 560px; } + +.body .columns td.one, +.body .column td.one { width: 8.333333%; } +.body .columns td.two, +.body .column td.two { width: 16.666666%; } +.body .columns td.three, +.body .column td.three { width: 25%; } +.body .columns td.four, +.body .column td.four { width: 33.333333%; } +.body .columns td.five, +.body .column td.five { width: 41.666666%; } +.body .columns td.six, +.body .column td.six { width: 50%; } +.body .columns td.seven, +.body .column td.seven { width: 58.333333%; } +.body .columns td.eight, +.body .column td.eight { width: 66.666666%; } +.body .columns td.nine, +.body .column td.nine { width: 75%; } +.body .columns td.ten, +.body .column td.ten { width: 83.333333%; } +.body .columns td.eleven, +.body .column td.eleven { width: 91.666666%; } +.body .columns td.twelve, +.body .column td.twelve { width: 100%; } + +td.offset-by-one { padding-left: 50px; } +td.offset-by-two { padding-left: 100px; } +td.offset-by-three { padding-left: 150px; } +td.offset-by-four { padding-left: 200px; } +td.offset-by-five { padding-left: 250px; } +td.offset-by-six { padding-left: 300px; } +td.offset-by-seven { padding-left: 350px; } +td.offset-by-eight { padding-left: 400px; } +td.offset-by-nine { padding-left: 450px; } +td.offset-by-ten { padding-left: 500px; } +td.offset-by-eleven { padding-left: 550px; } + +td.expander { + visibility: hidden; + width: 0px; + padding: 0 !important; +} + +table.columns .text-pad, +table.column .text-pad { + padding-left: 10px; + padding-right: 10px; +} + +table.columns .left-text-pad, +table.columns .text-pad-left, +table.column .left-text-pad, +table.column .text-pad-left { + padding-left: 10px; +} + +table.columns .right-text-pad, +table.columns .text-pad-right, +table.column .right-text-pad, +table.column .text-pad-right { + padding-right: 10px; +} + +/* Block Grid */ + +.block-grid { + width: 100%; + max-width: 580px; +} + +.block-grid td { + display: inline-block; + padding:10px; +} + +.two-up td { + width:270px; +} + +.three-up td { + width:173px; +} + +.four-up td { + width:125px; +} + +.five-up td { + width:96px; +} + +.six-up td { + width:76px; +} + +.seven-up td { + width:62px; +} + +.eight-up td { + width:52px; +} + +/* Alignment & Visibility Classes */ + +table.center, td.center { + text-align: center; +} + +h1.center, +h2.center, +h3.center, +h4.center, +h5.center, +h6.center { + text-align: center; +} + +span.center { + display: block; + width: 100%; + text-align: center; +} + +img.center { + margin: 0 auto; + float: none; +} + +.show-for-small, +.hide-for-desktop { + display: none; +} + +/* Typography */ + +body, table.body, h1, h2, h3, h4, h5, h6, p, td { + color: #222222; + font-family: "Helvetica", "Arial", sans-serif; + font-weight: normal; + padding:0; + margin: 0; + text-align: left; + line-height: 1.3; +} + +h1, h2, h3, h4, h5, h6 { + word-break: normal; +} + +h1 {font-size: 40px;} +h2 {font-size: 36px;} +h3 {font-size: 32px;} +h4 {font-size: 28px;} +h5 {font-size: 24px;} +h6 {font-size: 20px;} +body, table.body, p, td {font-size: 14px;line-height:19px;} + +p.lead, p.lede, p.leed { + font-size: 18px; + line-height:21px; +} + +p { + margin-bottom: 10px; +} + +small { + font-size: 10px; +} + +a { + color: #2ba6cb; + text-decoration: none; +} + +a:hover { + color: #2795b6 !important; +} + +a:active { + color: #2795b6 !important; +} + +a:visited { + color: #2ba6cb !important; +} + +h1 a, +h2 a, +h3 a, +h4 a, +h5 a, +h6 a { + color: #2ba6cb; +} + +h1 a:active, +h2 a:active, +h3 a:active, +h4 a:active, +h5 a:active, +h6 a:active { + color: #2ba6cb !important; +} + +h1 a:visited, +h2 a:visited, +h3 a:visited, +h4 a:visited, +h5 a:visited, +h6 a:visited { + color: #2ba6cb !important; +} + +/* Panels */ + +.panel { + background: #f2f2f2; + border: 1px solid #d9d9d9; + padding: 10px !important; +} + +.sub-grid table { + width: 100%; +} + +.sub-grid td.sub-columns { + padding-bottom: 0; +} + +/* Buttons */ + +table.button, +table.tiny-button, +table.small-button, +table.medium-button, +table.large-button { + width: 100%; + overflow: hidden; +} + +table.button td, +table.tiny-button td, +table.small-button td, +table.medium-button td, +table.large-button td { + display: block; + width: auto !important; + text-align: center; + background: #2ba6cb; + border: 1px solid #2284a1; + color: #ffffff; + padding: 8px 0; +} + +table.tiny-button td { + padding: 5px 0 4px; +} + +table.small-button td { + padding: 8px 0 7px; +} + +table.medium-button td { + padding: 12px 0 10px; +} + +table.large-button td { + padding: 21px 0 18px; +} + +table.button td a, +table.tiny-button td a, +table.small-button td a, +table.medium-button td a, +table.large-button td a { + font-weight: bold; + text-decoration: none; + font-family: Helvetica, Arial, sans-serif; + color: #ffffff; + font-size: 16px; +} + +table.tiny-button td a { + font-size: 12px; + font-weight: normal; +} + +table.small-button td a { + font-size: 16px; +} + +table.medium-button td a { + font-size: 20px; +} + +table.large-button td a { + font-size: 24px; +} + +table.button:hover td, +table.button:visited td, +table.button:active td { + background: #2795b6 !important; +} + +table.button:hover td a, +table.button:visited td a, +table.button:active td a { + color: #fff !important; +} + +table.button:hover td, +table.tiny-button:hover td, +table.small-button:hover td, +table.medium-button:hover td, +table.large-button:hover td { + background: #2795b6 !important; +} + +table.button:hover td a, +table.button:active td a, +table.button td a:visited, +table.tiny-button:hover td a, +table.tiny-button:active td a, +table.tiny-button td a:visited, +table.small-button:hover td a, +table.small-button:active td a, +table.small-button td a:visited, +table.medium-button:hover td a, +table.medium-button:active td a, +table.medium-button td a:visited, +table.large-button:hover td a, +table.large-button:active td a, +table.large-button td a:visited { + color: #ffffff !important; +} + +table.secondary td { + background: #e9e9e9; + border-color: #d0d0d0; + color: #555; +} + +table.secondary td a { + color: #555; +} + +table.secondary:hover td { + background: #d0d0d0 !important; + color: #555; +} + +table.secondary:hover td a, +table.secondary td a:visited, +table.secondary:active td a { + color: #555 !important; +} + +table.success td { + background: #5da423; + border-color: #457a1a; +} + +table.success:hover td { + background: #457a1a !important; +} + +table.alert td { + background: #c60f13; + border-color: #970b0e; +} + +table.alert:hover td { + background: #970b0e !important; +} + +table.radius td { + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; +} + +table.round td { + -webkit-border-radius: 500px; + -moz-border-radius: 500px; + border-radius: 500px; +} + +/* Outlook First */ + +body.outlook p { + display: inline !important; +} + +/* Media Queries */ + +@media only screen and (max-width: 600px) { + + table[class="body"] img { + width: auto !important; + height: auto !important; + } + + table[class="body"] center { + min-width: 0 !important; + } + + table[class="body"] .container { + width: 95% !important; + } + + table[class="body"] .row { + width: 100% !important; + display: block !important; + } + + table[class="body"] .wrapper { + display: block !important; + padding-right: 0 !important; + } + + table[class="body"] .columns, + table[class="body"] .column { + table-layout: fixed !important; + float: none !important; + width: 100% !important; + padding-right: 0px !important; + padding-left: 0px !important; + display: block !important; + } + + table[class="body"] .wrapper.first .columns, + table[class="body"] .wrapper.first .column { + display: table !important; + } + + table[class="body"] table.columns td, + table[class="body"] table.column td { + width: 100% !important; + } + + table[class="body"] .columns td.one, + table[class="body"] .column td.one { width: 8.333333% !important; } + table[class="body"] .columns td.two, + table[class="body"] .column td.two { width: 16.666666% !important; } + table[class="body"] .columns td.three, + table[class="body"] .column td.three { width: 25% !important; } + table[class="body"] .columns td.four, + table[class="body"] .column td.four { width: 33.333333% !important; } + table[class="body"] .columns td.five, + table[class="body"] .column td.five { width: 41.666666% !important; } + table[class="body"] .columns td.six, + table[class="body"] .column td.six { width: 50% !important; } + table[class="body"] .columns td.seven, + table[class="body"] .column td.seven { width: 58.333333% !important; } + table[class="body"] .columns td.eight, + table[class="body"] .column td.eight { width: 66.666666% !important; } + table[class="body"] .columns td.nine, + table[class="body"] .column td.nine { width: 75% !important; } + table[class="body"] .columns td.ten, + table[class="body"] .column td.ten { width: 83.333333% !important; } + table[class="body"] .columns td.eleven, + table[class="body"] .column td.eleven { width: 91.666666% !important; } + table[class="body"] .columns td.twelve, + table[class="body"] .column td.twelve { width: 100% !important; } + + table[class="body"] td.offset-by-one, + table[class="body"] td.offset-by-two, + table[class="body"] td.offset-by-three, + table[class="body"] td.offset-by-four, + table[class="body"] td.offset-by-five, + table[class="body"] td.offset-by-six, + table[class="body"] td.offset-by-seven, + table[class="body"] td.offset-by-eight, + table[class="body"] td.offset-by-nine, + table[class="body"] td.offset-by-ten, + table[class="body"] td.offset-by-eleven { + padding-left: 0 !important; + } + + table[class="body"] table.columns td.expander { + width: 1px !important; + } + + table[class="body"] .right-text-pad, + table[class="body"] .text-pad-right { + padding-left: 10px !important; + } + + table[class="body"] .left-text-pad, + table[class="body"] .text-pad-left { + padding-right: 10px !important; + } + + table[class="body"] .hide-for-small, + table[class="body"] .show-for-desktop { + display: none !important; + } + + table[class="body"] .show-for-small, + table[class="body"] .hide-for-desktop { + display: inherit !important; + } +} diff --git a/emails/assets/css/style.css b/emails/assets/css/style.css new file mode 100644 index 00000000000..47a5213e33e --- /dev/null +++ b/emails/assets/css/style.css @@ -0,0 +1,67 @@ + +body, table.body, h1, h2, h3, h4, h5, h6, p, td { + font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: none; +} + +table.facebook td { + background: #3b5998; + border-color: #2d4473; +} + +table.facebook:hover td { + background: #2d4473 !important; +} + +table.twitter td { + background: #00acee; + border-color: #0087bb; +} + +table.twitter:hover td { + background: #0087bb !important; +} + +table.google-plus td { + background-color: #DB4A39; + border-color: #CC0000; +} + +table.google-plus:hover td { + background: #CC0000 !important; +} + +.template-label { + color: #ffffff; + font-weight: bold; + font-size: 11px; +} + +.callout .wrapper { + padding-bottom: 20px; +} + +.callout .panel { + background: #ECF8FF; + border-color: #b9e5ff; +} + +.header { + background: #333; +} + +.footer { + margin-top: 20px; +} + +@media only screen and (max-width: 600px) { + table[class="body"] .right-text-pad { + padding-left: 10px !important; + } + + table[class="body"] .left-text-pad { + padding-right: 10px !important; + } +} + diff --git a/emails/grunt/aliases.yaml b/emails/grunt/aliases.yaml new file mode 100644 index 00000000000..6a2e47777cd --- /dev/null +++ b/emails/grunt/aliases.yaml @@ -0,0 +1,8 @@ + +default: + - 'clean' + - 'assemble' + - 'replace' + - 'uncss' + - 'processhtml' + - 'premailer' diff --git a/emails/grunt/assemble.js b/emails/grunt/assemble.js new file mode 100644 index 00000000000..a4a6390c4c2 --- /dev/null +++ b/emails/grunt/assemble.js @@ -0,0 +1,16 @@ +module.exports = function() { + 'use strict'; + return { + options: { + layout: 'templates/layouts/default.html', + partials: ['templates/partials/*.hbs'], + helpers: ['templates/helpers/**/*.js'], + data: [], + flatten: true + }, + pages: { + src: ['templates/*.html'], + dest: 'dist/' + } + }; +}; diff --git a/emails/grunt/clean.js b/emails/grunt/clean.js new file mode 100644 index 00000000000..60b8ff0a917 --- /dev/null +++ b/emails/grunt/clean.js @@ -0,0 +1,5 @@ +module.exports = function(config) { + return { + dist: ['dist'], + }; +}; diff --git a/emails/grunt/premailer.js b/emails/grunt/premailer.js new file mode 100644 index 00000000000..c50ca1af015 --- /dev/null +++ b/emails/grunt/premailer.js @@ -0,0 +1,14 @@ +module.exports = { + main: { + options: { + verbose: true, + removeComments: true + }, + files: [{ + expand: true, // Enable dynamic expansion. + cwd: 'dist', // Src matches are relative to this path. + src: ['*.html'], // Actual pattern(s) to match. + dest: '../public/emails/', // Destination path prefix. + }], + } +}; diff --git a/emails/grunt/processhtml.js b/emails/grunt/processhtml.js new file mode 100644 index 00000000000..efe34626a49 --- /dev/null +++ b/emails/grunt/processhtml.js @@ -0,0 +1,10 @@ +module.exports = { + dist: { + files: [{ + expand: true, // Enable dynamic expansion. + cwd: 'dist', // Src matches are relative to this path. + src: ['*.html'], // Actual pattern(s) to match. + dest: 'dist/', // Destination path prefix. + }], + } +}; diff --git a/emails/grunt/replace.js b/emails/grunt/replace.js new file mode 100644 index 00000000000..7038802d34e --- /dev/null +++ b/emails/grunt/replace.js @@ -0,0 +1,13 @@ +module.exports = { + dist: { + overwrite: true, + src: ['dist/*.html'], + replacements: [{ + from: '[[', + to: '{{' + }, { + from: ']]', + to: '}}' + }] + } +}; diff --git a/emails/grunt/uncss.js b/emails/grunt/uncss.js new file mode 100644 index 00000000000..114af6942c5 --- /dev/null +++ b/emails/grunt/uncss.js @@ -0,0 +1,9 @@ +module.exports = { + dist: { + src: ['dist/*.html'], + dest: 'dist/css/tidy.css', + options: { + report: 'min' // optional: include to report savings + } + } +}; diff --git a/emails/grunt/watch.js b/emails/grunt/watch.js new file mode 100644 index 00000000000..284851c500c --- /dev/null +++ b/emails/grunt/watch.js @@ -0,0 +1,16 @@ +module.exports = { + src: { + files: [ + //what are the files that we want to watch + 'assets/css/*.css', + 'templates/**/*.html', + 'grunt/*.js', + ], + tasks: ['default'], + options: { + nospawn: true, + livereload: true, + } + } + +}; diff --git a/emails/gruntfile.js b/emails/gruntfile.js new file mode 100644 index 00000000000..b6d54cf651b --- /dev/null +++ b/emails/gruntfile.js @@ -0,0 +1,6 @@ +module.exports = function(grunt) { + + // load grunt config + require('load-grunt-config')(grunt); + +}; diff --git a/emails/package.json b/emails/package.json new file mode 100644 index 00000000000..96779fb5609 --- /dev/null +++ b/emails/package.json @@ -0,0 +1,24 @@ +{ + "name": "Grafana-Email-Campaign", + "version": "1.0.0", + "description": "Grafana Email templates based on Zurb Ink", + "repository": "dnnsldr/", + "author": { + "name": "dnnsldr", + "email": "delder@riester.com", + "url": "https://github.com/dnnsldr" + }, + "devDependencies": { + "grunt": "^0.4.5", + "grunt-premailer": "^0.2.10", + "grunt-processhtml": "^0.3.3", + "grunt-uncss": "^0.3.7", + "load-grunt-config": "^0.14.0", + "grunt-contrib-watch": "^0.6.1", + "grunt-text-replace": "^0.3.12" + }, + "dependencies": { + "grunt-assemble": "^0.4.0", + "grunt-contrib-clean": "^0.6.0" + } +} diff --git a/emails/templates/invited_to_org.html b/emails/templates/invited_to_org.html new file mode 100644 index 00000000000..5aac6e28de3 --- /dev/null +++ b/emails/templates/invited_to_org.html @@ -0,0 +1,38 @@ + + +[[Subject .Subject "[[.InvitedBy]] has added you to the Grafana organization [[.OrgName]]"]] + + + + + +
+ + + + + + +
+

You have been added to the Grafana organization [[.OrgName]]

+
+ +
+ + + + + +
+ + + + + +
+

You can switch organization in the left side menu, in the dropdown below your username.

+
+ +
+ + diff --git a/emails/templates/layouts/default.html b/emails/templates/layouts/default.html new file mode 100644 index 00000000000..0fd5b35ea48 --- /dev/null +++ b/emails/templates/layouts/default.html @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + +
+ + + + + + +
+ +
+ +
+ +
+
+ + + + + + +
+ {{> body }} + + + + + + + + +
+
+
+ + diff --git a/emails/templates/new_user_invite.html b/emails/templates/new_user_invite.html new file mode 100644 index 00000000000..1368ba5a457 --- /dev/null +++ b/emails/templates/new_user_invite.html @@ -0,0 +1,44 @@ + + +[[Subject .Subject "[[.InvitedBy]] has invited you to join Grafana"]] + + + + + +
+ + + + + + +
+

You're invited to sign up to Grafana and join organization [[.OrgName]]

+
+ +
+ + + + + +
+ + + + + +
+ + + + +
+ Complete Sign Up +
+
+ +
+ + diff --git a/emails/templates/reset_password.html b/emails/templates/reset_password.html new file mode 100644 index 00000000000..f3aca5da95e --- /dev/null +++ b/emails/templates/reset_password.html @@ -0,0 +1,42 @@ +[[Subject .Subject "Reset your Grafana password - [[.Name]]"]] + + + + + +
+ + + + + + +
+

Hi [[.Name]]

+
+ +
+ + + + + +
+ + + + + +
+

+ Please click the following link to reset your password within [[.EmailCodeValidHours]] hours. +

+

+ [[.AppUrl]]user/password/reset?code=[[.Code]] +

+

Not working? Try copying and pasting it to your browser.

+
+ +
+ + diff --git a/emails/templates/welcome_on_signup.html b/emails/templates/welcome_on_signup.html new file mode 100644 index 00000000000..b93c56b77b1 --- /dev/null +++ b/emails/templates/welcome_on_signup.html @@ -0,0 +1,37 @@ +[[Subject .Subject "Welcome to Grafana"]] + + + + + +
+ + + + + + +
+

Hi [[.Name]]

+
+ +
+ + + + + +
+ + + + + +
+

+ If you are new to Grafana please read the Getting Started guide. +

+
+
+ + diff --git a/pkg/api/api.go b/pkg/api/api.go index fd4160f9993..7ad4ae73b4c 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -22,6 +22,7 @@ func Register(r *macaron.Macaron) { r.Post("/login", bind(dtos.LoginCommand{}), wrap(LoginPost)) r.Get("/login/:name", OAuthLogin) r.Get("/login", LoginView) + r.Get("/invite/:code", Index) // authed views r.Get("/profile/", reqSignedIn, Index) @@ -42,6 +43,10 @@ func Register(r *macaron.Macaron) { r.Get("/signup", Index) r.Post("/api/user/signup", bind(m.CreateUserCommand{}), wrap(SignUp)) + // invited + r.Get("/api/user/invite/:code", wrap(GetInviteInfoByCode)) + r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), wrap(CompleteInvite)) + // reset password r.Get("/user/password/send-reset-email", Index) r.Get("/user/password/reset", Index) @@ -89,6 +94,11 @@ func Register(r *macaron.Macaron) { r.Get("/users", wrap(GetOrgUsersForCurrentOrg)) r.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUserForCurrentOrg)) r.Delete("/users/:userId", wrap(RemoveOrgUserForCurrentOrg)) + + // invites + r.Get("/invites", wrap(GetPendingOrgInvites)) + r.Post("/invites", bind(dtos.AddInviteForm{}), wrap(AddOrgInvite)) + r.Patch("/invites/:code/revoke", wrap(RevokeInvite)) }, regOrgAdmin) // create new org diff --git a/pkg/api/dtos/invite.go b/pkg/api/dtos/invite.go new file mode 100644 index 00000000000..3f002a8b157 --- /dev/null +++ b/pkg/api/dtos/invite.go @@ -0,0 +1,26 @@ +package dtos + +import m "github.com/grafana/grafana/pkg/models" + +type AddInviteForm struct { + LoginOrEmail string `json:"loginOrEmail" binding:"Required"` + Name string `json:"name"` + Role m.RoleType `json:"role" binding:"Required"` + SkipEmails bool `json:"skipEmails"` +} + +type InviteInfo struct { + Email string `json:"email"` + Name string `json:"name"` + Username string `json:"username"` + InvitedBy string `json:"invitedBy"` +} + +type CompleteInviteForm struct { + InviteCode string `json:"inviteCode"` + Email string `json:"email" binding:"Required"` + Name string `json:"name"` + Username string `json:"username"` + Password string `json:"password"` + ConfirmPassword string `json:"confirmPassword"` +} diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go new file mode 100644 index 00000000000..ecad941ede3 --- /dev/null +++ b/pkg/api/org_invite.go @@ -0,0 +1,204 @@ +package api + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/events" + "github.com/grafana/grafana/pkg/metrics" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" +) + +func GetPendingOrgInvites(c *middleware.Context) Response { + query := m.GetTempUsersForOrgQuery{OrgId: c.OrgId, Status: m.TmpUserInvitePending} + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Failed to get invites from db", err) + } + + for _, invite := range query.Result { + invite.Url = setting.ToAbsUrl("invite/" + invite.Code) + } + + return Json(200, query.Result) +} + +func AddOrgInvite(c *middleware.Context, inviteDto dtos.AddInviteForm) Response { + if !inviteDto.Role.IsValid() { + return ApiError(400, "Invalid role specified", nil) + } + + // first try get existing user + userQuery := m.GetUserByLoginQuery{LoginOrEmail: inviteDto.LoginOrEmail} + if err := bus.Dispatch(&userQuery); err != nil { + if err != m.ErrUserNotFound { + return ApiError(500, "Failed to query db for existing user check", err) + } + } else { + return inviteExistingUserToOrg(c, userQuery.Result, &inviteDto) + } + + cmd := m.CreateTempUserCommand{} + cmd.OrgId = c.OrgId + cmd.Email = inviteDto.LoginOrEmail + cmd.Name = inviteDto.Name + cmd.Status = m.TmpUserInvitePending + cmd.InvitedByUserId = c.UserId + cmd.Code = util.GetRandomString(30) + cmd.Role = inviteDto.Role + cmd.RemoteAddr = c.Req.RemoteAddr + + if err := bus.Dispatch(&cmd); err != nil { + return ApiError(500, "Failed to save invite to database", err) + } + + // send invite email + if !inviteDto.SkipEmails && util.IsEmail(inviteDto.LoginOrEmail) { + emailCmd := m.SendEmailCommand{ + To: []string{inviteDto.LoginOrEmail}, + Template: "new_user_invite.html", + Data: map[string]interface{}{ + "Name": util.StringsFallback2(cmd.Name, cmd.Email), + "OrgName": c.OrgName, + "Email": c.Email, + "LinkUrl": setting.ToAbsUrl("invite/" + cmd.Code), + "InvitedBy": util.StringsFallback3(c.Name, c.Email, c.Login), + }, + } + + if err := bus.Dispatch(&emailCmd); err != nil { + return ApiError(500, "Failed to send email invite", err) + } + + return ApiSuccess(fmt.Sprintf("Sent invite to %s", inviteDto.LoginOrEmail)) + } + + return ApiSuccess(fmt.Sprintf("Created invite for %s", inviteDto.LoginOrEmail)) +} + +func inviteExistingUserToOrg(c *middleware.Context, user *m.User, inviteDto *dtos.AddInviteForm) Response { + // user exists, add org role + createOrgUserCmd := m.AddOrgUserCommand{OrgId: c.OrgId, UserId: user.Id, Role: inviteDto.Role} + if err := bus.Dispatch(&createOrgUserCmd); err != nil { + if err == m.ErrOrgUserAlreadyAdded { + return ApiError(412, fmt.Sprintf("User %s is already added to organization", inviteDto.LoginOrEmail), err) + } + return ApiError(500, "Error while trying to create org user", err) + } else { + + if !inviteDto.SkipEmails && util.IsEmail(user.Email) { + emailCmd := m.SendEmailCommand{ + To: []string{user.Email}, + Template: "invited_to_org.html", + Data: map[string]interface{}{ + "Name": user.NameOrFallback(), + "OrgName": c.OrgName, + "InvitedBy": util.StringsFallback3(c.Name, c.Email, c.Login), + }, + } + + if err := bus.Dispatch(&emailCmd); err != nil { + return ApiError(500, "Failed to send email invited_to_org", err) + } + } + + return ApiSuccess(fmt.Sprintf("Existing Grafana user %s added to org %s", user.NameOrFallback(), c.OrgName)) + } +} + +func RevokeInvite(c *middleware.Context) Response { + cmd := m.UpdateTempUserStatusCommand{ + Code: c.Params(":code"), + Status: m.TmpUserRevoked, + } + + if err := bus.Dispatch(&cmd); err != nil { + return ApiError(500, "Failed to update invite status", err) + } + + return ApiSuccess("Invite revoked") +} + +func GetInviteInfoByCode(c *middleware.Context) Response { + query := m.GetTempUserByCodeQuery{Code: c.Params(":code")} + + if err := bus.Dispatch(&query); err != nil { + if err == m.ErrTempUserNotFound { + return ApiError(404, "Invite not found", nil) + } + return ApiError(500, "Failed to get invite", err) + } + + invite := query.Result + + return Json(200, dtos.InviteInfo{ + Email: invite.Email, + Name: invite.Name, + Username: invite.Email, + InvitedBy: util.StringsFallback3(invite.InvitedByName, invite.InvitedByLogin, invite.InvitedByEmail), + }) +} + +func CompleteInvite(c *middleware.Context, completeInvite dtos.CompleteInviteForm) Response { + query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode} + + if err := bus.Dispatch(&query); err != nil { + if err == m.ErrTempUserNotFound { + return ApiError(404, "Invite not found", nil) + } + return ApiError(500, "Failed to get invite", err) + } + + invite := query.Result + if invite.Status != m.TmpUserInvitePending { + return ApiError(412, fmt.Sprintf("Invite cannot be used in status %s", invite.Status), nil) + } + + cmd := m.CreateUserCommand{ + Email: completeInvite.Email, + Name: completeInvite.Name, + Login: completeInvite.Username, + Password: completeInvite.Password, + } + + if err := bus.Dispatch(&cmd); err != nil { + return ApiError(500, "failed to create user", err) + } + + user := cmd.Result + + bus.Publish(&events.UserSignedUp{ + Id: user.Id, + Name: user.Name, + Email: user.Email, + Login: user.Login, + }) + + // add to org + addOrgUserCmd := m.AddOrgUserCommand{OrgId: invite.OrgId, UserId: user.Id, Role: invite.Role} + if err := bus.Dispatch(&addOrgUserCmd); err != nil { + return ApiError(500, "Error while trying to create org user", err) + } + + // set org to active + if err := bus.Dispatch(&m.SetUsingOrgCommand{OrgId: invite.OrgId, UserId: user.Id}); err != nil { + return ApiError(500, "Failed to set org as active", err) + } + + // update temp user status + updateTmpUserCmd := m.UpdateTempUserStatusCommand{Code: invite.Code, Status: m.TmpUserCompleted} + if err := bus.Dispatch(&updateTmpUserCmd); err != nil { + return ApiError(500, "Failed to update invite status", err) + } + + loginUserWithUser(&user, c) + + metrics.M_Api_User_SignUp.Inc(1) + metrics.M_Api_User_SignUpInvite.Inc(1) + + return ApiSuccess("User created and logged in") +} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index f6dab8c8043..0850cbebc06 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -14,6 +14,7 @@ var ( M_Api_Status_404 = NewComboCounterRef("api.status.404") M_Api_User_SignUp = NewComboCounterRef("api.user.signup") + M_Api_User_SignUpInvite = NewComboCounterRef("api.user.signup_invite") M_Api_Dashboard_Get = NewComboCounterRef("api.dashboard.get") M_Api_Dashboard_Post = NewComboCounterRef("api.dashboard.post") M_Api_Admin_User_Create = NewComboCounterRef("api.admin.user_create") diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index afbb10386c8..f456fa95373 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -7,9 +7,10 @@ import ( // Typed errors var ( - ErrInvalidRoleType = errors.New("Invalid role type") - ErrLastOrgAdmin = errors.New("Cannot remove last organization admin") - ErrOrgUserNotFound = errors.New("Cannot find the organization user") + ErrInvalidRoleType = errors.New("Invalid role type") + ErrLastOrgAdmin = errors.New("Cannot remove last organization admin") + ErrOrgUserNotFound = errors.New("Cannot find the organization user") + ErrOrgUserAlreadyAdded = errors.New("User is already added to organization") ) type RoleType string diff --git a/pkg/models/temp_user.go b/pkg/models/temp_user.go new file mode 100644 index 00000000000..6aeb67cda9b --- /dev/null +++ b/pkg/models/temp_user.go @@ -0,0 +1,91 @@ +package models + +import ( + "errors" + "time" +) + +// Typed errors +var ( + ErrTempUserNotFound = errors.New("User not found") +) + +type TempUserStatus string + +const ( + TmpUserInvitePending TempUserStatus = "InvitePending" + TmpUserCompleted TempUserStatus = "Completed" + TmpUserEmailPending TempUserStatus = "EmailPending" + TmpUserRevoked TempUserStatus = "Revoked" +) + +// TempUser holds data for org invites and unconfirmed sign ups +type TempUser struct { + Id int64 + OrgId int64 + Version int + Email string + Name string + Role RoleType + InvitedByUserId int64 + Status TempUserStatus + + EmailSent bool + EmailSentOn time.Time + Code string + RemoteAddr string + + Created time.Time + Updated time.Time +} + +// --------------------- +// COMMANDS + +type CreateTempUserCommand struct { + Email string + Name string + OrgId int64 + InvitedByUserId int64 + Status TempUserStatus + Code string + Role RoleType + RemoteAddr string + + Result *TempUser +} + +type UpdateTempUserStatusCommand struct { + Code string + Status TempUserStatus +} + +type GetTempUsersForOrgQuery struct { + OrgId int64 + Status TempUserStatus + + Result []*TempUserDTO +} + +type GetTempUserByCodeQuery struct { + Code string + + Result *TempUserDTO +} + +type TempUserDTO struct { + Id int64 `json:"id"` + OrgId int64 `json:"orgId"` + Name string `json:"name"` + Email string `json:"email"` + Role RoleType `json:"role"` + InvitedByLogin string `json:"invitedByLogin"` + InvitedByEmail string `json:"invitedByEmail"` + InvitedByName string `json:"invitedByName"` + Code string `json:"code"` + Status TempUserStatus `json:"status"` + Url string `json:"url"` + EmailSent bool `json:"emailSent"` + EmailSentOn time.Time `json:"emailSentOn"` + Created time.Time `json:"createdOn"` +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 329c6187c9d..70acc9ef736 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -10,6 +10,7 @@ import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" func AddMigrations(mg *Migrator) { addMigrationLogMigrations(mg) addUserMigrations(mg) + addTempUserMigrations(mg) addStarMigrations(mg) addOrgMigrations(mg) addDashboardMigration(mg) diff --git a/pkg/services/sqlstore/migrations/temp_user.go b/pkg/services/sqlstore/migrations/temp_user.go new file mode 100644 index 00000000000..36ae2706e57 --- /dev/null +++ b/pkg/services/sqlstore/migrations/temp_user.go @@ -0,0 +1,38 @@ +package migrations + +import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addTempUserMigrations(mg *Migrator) { + tempUserV1 := Table{ + Name: "temp_user", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "org_id", Type: DB_BigInt, Nullable: false}, + {Name: "version", Type: DB_Int, Nullable: false}, + {Name: "email", Type: DB_NVarchar, Length: 255}, + {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "role", Type: DB_NVarchar, Length: 20, Nullable: true}, + {Name: "code", Type: DB_NVarchar, Length: 255}, + {Name: "status", Type: DB_Varchar, Length: 20}, + {Name: "invited_by_user_id", Type: DB_BigInt, Nullable: true}, + {Name: "email_sent", Type: DB_Bool}, + {Name: "email_sent_on", Type: DB_DateTime, Nullable: true}, + {Name: "remote_addr", Type: DB_Varchar, Nullable: true}, + {Name: "created", Type: DB_DateTime}, + {Name: "updated", Type: DB_DateTime}, + }, + Indices: []*Index{ + {Cols: []string{"email"}, Type: IndexType}, + {Cols: []string{"org_id"}, Type: IndexType}, + {Cols: []string{"code"}, Type: IndexType}, + {Cols: []string{"status"}, Type: IndexType}, + }, + } + + // addDropAllIndicesMigrations(mg, "v7", tempUserV1) + // mg.AddMigration("Drop old table tempUser v7", NewDropTableMigration("temp_user")) + + // create table + mg.AddMigration("create temp user table v1-7", NewAddTableMigration(tempUserV1)) + addTableIndicesMigrations(mg, "v1-7", tempUserV1) +} diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 2e8fc40cb7c..fdd671d0bfe 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -19,6 +19,12 @@ func init() { func AddOrgUser(cmd *m.AddOrgUserCommand) error { return inTransaction(func(sess *xorm.Session) error { + // check if user exists + if res, err := sess.Query("SELECT 1 from org_user WHERE org_id=? and user_id=?", cmd.OrgId, cmd.UserId); err != nil { + return err + } else if len(res) == 1 { + return m.ErrOrgUserAlreadyAdded + } entity := m.OrgUser{ OrgId: cmd.OrgId, diff --git a/pkg/services/sqlstore/sqlstore.goconvey b/pkg/services/sqlstore/sqlstore.goconvey index 92feb5268a5..8ca30789e4a 100644 --- a/pkg/services/sqlstore/sqlstore.goconvey +++ b/pkg/services/sqlstore/sqlstore.goconvey @@ -1 +1 @@ --timeout=10s +-timeout=20s diff --git a/pkg/services/sqlstore/temp_user.go b/pkg/services/sqlstore/temp_user.go new file mode 100644 index 00000000000..0cac59b21f6 --- /dev/null +++ b/pkg/services/sqlstore/temp_user.go @@ -0,0 +1,108 @@ +package sqlstore + +import ( + "time" + + "github.com/go-xorm/xorm" + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" +) + +func init() { + bus.AddHandler("sql", CreateTempUser) + bus.AddHandler("sql", GetTempUsersForOrg) + bus.AddHandler("sql", UpdateTempUserStatus) + bus.AddHandler("sql", GetTempUserByCode) +} + +func UpdateTempUserStatus(cmd *m.UpdateTempUserStatusCommand) error { + return inTransaction(func(sess *xorm.Session) error { + var rawSql = "UPDATE temp_user SET status=? WHERE code=?" + _, err := sess.Exec(rawSql, string(cmd.Status), cmd.Code) + return err + }) +} + +func CreateTempUser(cmd *m.CreateTempUserCommand) error { + return inTransaction2(func(sess *session) error { + + // create user + user := &m.TempUser{ + Email: cmd.Email, + Name: cmd.Name, + OrgId: cmd.OrgId, + Code: cmd.Code, + Role: cmd.Role, + Status: cmd.Status, + RemoteAddr: cmd.RemoteAddr, + InvitedByUserId: cmd.InvitedByUserId, + Created: time.Now(), + Updated: time.Now(), + } + + if _, err := sess.Insert(user); err != nil { + return err + } + + cmd.Result = user + return nil + }) +} + +func GetTempUsersForOrg(query *m.GetTempUsersForOrgQuery) error { + var rawSql = `SELECT + tu.id as id, + tu.org_id as org_id, + tu.email as email, + tu.name as name, + tu.role as role, + tu.code as code, + tu.status as status, + tu.email_sent as email_sent, + tu.email_sent_on as email_sent_on, + tu.created as created, + u.login as invited_by_login, + u.name as invited_by_name, + u.email as invited_by_email + FROM ` + dialect.Quote("temp_user") + ` as tu + LEFT OUTER JOIN ` + dialect.Quote("user") + ` as u on u.id = tu.invited_by_user_id + WHERE tu.org_id=? AND tu.status =? ORDER BY tu.created desc` + + query.Result = make([]*m.TempUserDTO, 0) + sess := x.Sql(rawSql, query.OrgId, string(query.Status)) + err := sess.Find(&query.Result) + return err +} + +func GetTempUserByCode(query *m.GetTempUserByCodeQuery) error { + var rawSql = `SELECT + tu.id as id, + tu.org_id as org_id, + tu.email as email, + tu.name as name, + tu.role as role, + tu.code as code, + tu.status as status, + tu.email_sent as email_sent, + tu.email_sent_on as email_sent_on, + tu.created as created, + u.login as invited_by_login, + u.name as invited_by_name, + u.email as invited_by_email + FROM ` + dialect.Quote("temp_user") + ` as tu + LEFT OUTER JOIN ` + dialect.Quote("user") + ` as u on u.id = tu.invited_by_user_id + WHERE tu.code=?` + + var tempUser m.TempUserDTO + sess := x.Sql(rawSql, query.Code) + has, err := sess.Get(&tempUser) + + if err != nil { + return err + } else if has == false { + return m.ErrTempUserNotFound + } + + query.Result = &tempUser + return err +} diff --git a/pkg/services/sqlstore/temp_user_test.go b/pkg/services/sqlstore/temp_user_test.go new file mode 100644 index 00000000000..6b550799e73 --- /dev/null +++ b/pkg/services/sqlstore/temp_user_test.go @@ -0,0 +1,51 @@ +package sqlstore + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" + + m "github.com/grafana/grafana/pkg/models" +) + +func TestTempUserCommandsAndQueries(t *testing.T) { + + Convey("Testing Temp User commands & queries", t, func() { + InitTestDB(t) + + Convey("Given saved api key", func() { + cmd := m.CreateTempUserCommand{ + OrgId: 2256, + Name: "hello", + Code: "asd", + Email: "e@as.co", + Status: m.TmpUserInvitePending, + } + err := CreateTempUser(&cmd) + So(err, ShouldBeNil) + + Convey("Should be able to get temp users by org id", func() { + query := m.GetTempUsersForOrgQuery{OrgId: 2256, Status: m.TmpUserInvitePending} + err = GetTempUsersForOrg(&query) + + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 1) + }) + + Convey("Should be able to get temp users by code", func() { + query := m.GetTempUserByCodeQuery{Code: "asd"} + err = GetTempUserByCode(&query) + + So(err, ShouldBeNil) + So(query.Result.Name, ShouldEqual, "hello") + }) + + Convey("Should be able update status", func() { + cmd2 := m.UpdateTempUserStatusCommand{Code: "asd", Status: m.TmpUserRevoked} + err := UpdateTempUserStatus(&cmd2) + So(err, ShouldBeNil) + }) + + }) + }) +} diff --git a/pkg/util/strings.go b/pkg/util/strings.go new file mode 100644 index 00000000000..7e503a99118 --- /dev/null +++ b/pkg/util/strings.go @@ -0,0 +1,18 @@ +package util + +func StringsFallback2(val1 string, val2 string) string { + if val1 != "" { + return val1 + } + return val2 +} + +func StringsFallback3(val1 string, val2 string, val3 string) string { + if val1 != "" { + return val1 + } + if val2 != "" { + return val2 + } + return val3 +} diff --git a/public/app/app.js b/public/app/app.js index 4d1dec4f673..8adc04d1ef7 100644 --- a/public/app/app.js +++ b/public/app/app.js @@ -12,6 +12,7 @@ define([ 'angular-sanitize', 'angular-strap', 'angular-dragdrop', + 'angular-ui', 'extend-jquery', 'bindonce', ], @@ -64,7 +65,8 @@ function (angular, $, _, appLevelRequire) { '$strap.directives', 'ang-drag-drop', 'grafana', - 'pasvaz.bindonce' + 'pasvaz.bindonce', + 'ui.bootstrap.tabs', ]; var module_types = ['controllers', 'directives', 'factories', 'services', 'filters', 'routes']; diff --git a/public/app/components/require.config.js b/public/app/components/require.config.js index 12a72dd42e3..31825804d04 100644 --- a/public/app/components/require.config.js +++ b/public/app/components/require.config.js @@ -17,6 +17,7 @@ require.config({ 'angular-sanitize': '../vendor/angular-sanitize/angular-sanitize', 'angular-dragdrop': '../vendor/angular-native-dragdrop/draganddrop', 'angular-strap': '../vendor/angular-other/angular-strap', + 'angular-ui': '../vendor/angular-ui/angular-bootstrap', timepicker: '../vendor/angular-other/timepicker', datepicker: '../vendor/angular-other/datepicker', bindonce: '../vendor/angular-bindonce/bindonce', @@ -90,6 +91,7 @@ require.config({ 'angular-dragdrop': ['jquery', 'angular'], 'angular-mocks': ['angular'], 'angular-sanitize': ['angular'], + 'angular-ui': ['angular'], 'angular-route': ['angular'], 'angular-strap': ['angular', 'bootstrap','timepicker', 'datepicker'], 'bindonce': ['angular'], diff --git a/public/app/controllers/all.js b/public/app/controllers/all.js index 99b9a496484..500feea3516 100644 --- a/public/app/controllers/all.js +++ b/public/app/controllers/all.js @@ -6,6 +6,7 @@ define([ './inspectCtrl', './jsonEditorCtrl', './loginCtrl', + './invitedCtrl', './resetPasswordCtrl', './sidemenuCtrl', './errorCtrl', diff --git a/public/app/controllers/invitedCtrl.js b/public/app/controllers/invitedCtrl.js new file mode 100644 index 00000000000..0b4a096fb1b --- /dev/null +++ b/public/app/controllers/invitedCtrl.js @@ -0,0 +1,41 @@ +define([ + 'angular', + 'config', +], +function (angular, config) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('InvitedCtrl', function($scope, $routeParams, contextSrv, backendSrv) { + + contextSrv.sidemenu = false; + + $scope.formModel = {}; + + $scope.init = function() { + backendSrv.get('/api/user/invite/' + $routeParams.code).then(function(invite) { + $scope.formModel.name = invite.name; + $scope.formModel.email = invite.email; + $scope.formModel.username = invite.email; + $scope.formModel.inviteCode = $routeParams.code; + + $scope.greeting = invite.name || invite.email || invite.username; + $scope.invitedBy = invite.invitedBy; + }); + }; + + $scope.submit = function() { + if (!$scope.inviteForm.$valid) { + return; + } + + backendSrv.post('/api/user/invite/complete', $scope.formModel).then(function() { + window.location.href = config.appSubUrl + '/'; + }); + }; + + $scope.init(); + + }); +}); diff --git a/public/app/features/admin/partials/edit_user.html b/public/app/features/admin/partials/edit_user.html index a1a4cb989cd..674f1cf4b0d 100644 --- a/public/app/features/admin/partials/edit_user.html +++ b/public/app/features/admin/partials/edit_user.html @@ -9,7 +9,7 @@

- User details + Edit User

@@ -17,7 +17,7 @@
  • - Name + Name
  • @@ -28,7 +28,7 @@
    • - Email + Email
    • @@ -39,7 +39,7 @@
      • - Username + Username
      • @@ -53,16 +53,16 @@
      • -

        +

        Change password -

        +
        • - New password + New password
        • @@ -76,9 +76,9 @@
        • -

          +

          Permissions -

          +
          @@ -97,9 +97,9 @@
          -

          +

          Organizations -

          +
          diff --git a/public/app/features/dashboard/unsavedChangesSrv.js b/public/app/features/dashboard/unsavedChangesSrv.js index eeff9ae05c7..758c06b1975 100644 --- a/public/app/features/dashboard/unsavedChangesSrv.js +++ b/public/app/features/dashboard/unsavedChangesSrv.js @@ -7,7 +7,7 @@ function(angular, _) { var module = angular.module('grafana.services'); - module.service('unsavedChangesSrv', function($modal, $q, $location, $timeout, contextSrv, $window) { + module.service('unsavedChangesSrv', function($rootScope, $q, $location, $timeout, contextSrv, $window) { function Tracker(dashboard, scope) { var self = this; @@ -142,17 +142,10 @@ function(angular, _) { tracker.scope.$emit('save-dashboard'); }; - var confirmModal = $modal({ - template: './app/partials/unsaved-changes.html', - modalClass: 'confirm-modal', - persist: false, - show: false, + $rootScope.appEvent('show-modal', { + src: './app/partials/unsaved-changes.html', + modalClass: 'modal-no-header confirm-modal', scope: modalScope, - keyboard: false - }); - - $q.when(confirmModal).then(function(modalEl) { - modalEl.modal('show'); }); }; diff --git a/public/app/features/org/all.js b/public/app/features/org/all.js index ba51b99369b..d03d270709d 100644 --- a/public/app/features/org/all.js +++ b/public/app/features/org/all.js @@ -3,6 +3,7 @@ define([ './datasourceEditCtrl', './orgUsersCtrl', './newOrgCtrl', + './userInviteCtrl', './orgApiKeysCtrl', './orgDetailsCtrl', ], function () {}); diff --git a/public/app/features/org/orgUsersCtrl.js b/public/app/features/org/orgUsersCtrl.js index 9623e2566a7..3d3e4d26ec3 100644 --- a/public/app/features/org/orgUsersCtrl.js +++ b/public/app/features/org/orgUsersCtrl.js @@ -13,14 +13,21 @@ function (angular) { role: 'Viewer', }; + $scope.users = []; + $scope.pendingInvites = []; + $scope.init = function() { $scope.get(); + $scope.editor = { index: 0 }; }; $scope.get = function() { backendSrv.get('/api/org/users').then(function(users) { $scope.users = users; }); + backendSrv.get('/api/org/invites').then(function(pendingInvites) { + $scope.pendingInvites = pendingInvites; + }); }; $scope.updateOrgUser = function(user) { @@ -31,9 +38,26 @@ function (angular) { backendSrv.delete('/api/org/users/' + user.userId).then($scope.get); }; - $scope.addUser = function() { - if (!$scope.form.$valid) { return; } - backendSrv.post('/api/org/users', $scope.user).then($scope.get); + $scope.revokeInvite = function(invite, evt) { + evt.stopPropagation(); + backendSrv.patch('/api/org/invites/' + invite.code + '/revoke').then($scope.get); + }; + + $scope.copyInviteToClipboard = function(evt) { + evt.stopPropagation(); + }; + + $scope.openInviteModal = function() { + var modalScope = $scope.$new(); + modalScope.invitesSent = function() { + $scope.get(); + }; + + $scope.appEvent('show-modal', { + src: './app/features/org/partials/invite.html', + modalClass: 'modal-no-header invite-modal', + scope: modalScope + }); }; $scope.init(); diff --git a/public/app/features/org/partials/invite.html b/public/app/features/org/partials/invite.html new file mode 100644 index 00000000000..808f722c081 --- /dev/null +++ b/public/app/features/org/partials/invite.html @@ -0,0 +1,70 @@ + + + diff --git a/public/app/features/org/partials/orgUsers.html b/public/app/features/org/partials/orgUsers.html index b32ffb67081..355131c2dbb 100644 --- a/public/app/features/org/partials/orgUsers.html +++ b/public/app/features/org/partials/orgUsers.html @@ -9,53 +9,67 @@

          Organization users

          -
          -
          -
            -
          • - Username or Email -
          • -
          • - -
          • -
          • - role -
          • -
          • - -
          • -
          • - -
          • -
            -
          -
          -
          +
          - - - - - - - - - - - - - -
          LoginEmailRole
          {{user.login}}{{user.email}} - - - - - -
          + + + + + + + + + + + + + + + +
          LoginEmailRole
          {{user.login}}{{user.email}} + + + + + +
          +
          + +
          + {{invite.email}} + {{invite.name}} + + +   + + + + + +
          + {{invite.url}}
          + +   + + + Invited: {{invite.createdOn | date: 'shortDate'}} by {{invite.invitedBy}} + +
          +
          + +
          diff --git a/public/app/features/org/userInviteCtrl.js b/public/app/features/org/userInviteCtrl.js new file mode 100644 index 00000000000..52b307612b3 --- /dev/null +++ b/public/app/features/org/userInviteCtrl.js @@ -0,0 +1,48 @@ +define([ + 'angular', + 'lodash', +], +function (angular, _) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('UserInviteCtrl', function($scope, backendSrv) { + + $scope.invites = [ + {name: '', email: '', role: 'Editor'}, + ]; + + $scope.options = {skipEmails: false}; + $scope.init = function() { }; + + $scope.addInvite = function() { + $scope.invites.push({name: '', email: '', role: 'Editor'}); + }; + + $scope.removeInvite = function(invite) { + $scope.invites = _.without($scope.invites, invite); + }; + + $scope.sendInvites = function() { + if (!$scope.inviteForm.$valid) { return; } + $scope.sendSingleInvite(0); + }; + + $scope.sendSingleInvite = function(index) { + var invite = $scope.invites[index]; + invite.skipEmails = $scope.options.skipEmails; + + return backendSrv.post('/api/org/invites', invite).finally(function() { + index += 1; + + if (index === $scope.invites.length) { + $scope.invitesSent(); + $scope.dismiss(); + } else { + $scope.sendSingleInvite(index); + } + }); + }; + }); +}); diff --git a/public/app/features/profile/partials/profile.html b/public/app/features/profile/partials/profile.html index 5512245b275..fde49edcc72 100644 --- a/public/app/features/profile/partials/profile.html +++ b/public/app/features/profile/partials/profile.html @@ -8,7 +8,7 @@
          -

          Profile details

          +

          Profile

          @@ -64,7 +64,7 @@ -

          Organizations

          +

          Organizations

          diff --git a/public/app/partials/bootstrap/tab.html b/public/app/partials/bootstrap/tab.html new file mode 100644 index 00000000000..d76dd67caf2 --- /dev/null +++ b/public/app/partials/bootstrap/tab.html @@ -0,0 +1,3 @@ +
        • + {{heading}} +
        • diff --git a/public/app/partials/bootstrap/tabset.html b/public/app/partials/bootstrap/tabset.html new file mode 100644 index 00000000000..acbad38390b --- /dev/null +++ b/public/app/partials/bootstrap/tabset.html @@ -0,0 +1,10 @@ +
          + +
          +
          +
          +
          +
          diff --git a/public/app/partials/confirm_modal.html b/public/app/partials/confirm_modal.html index 8730cfb1596..24802814be1 100644 --- a/public/app/partials/confirm_modal.html +++ b/public/app/partials/confirm_modal.html @@ -12,7 +12,7 @@ {{title}} -
          + diff --git a/public/app/partials/login.html b/public/app/partials/login.html index a5894866d5a..f311c929b66 100644 --- a/public/app/partials/login.html +++ b/public/app/partials/login.html @@ -1,4 +1,6 @@
          +
          + + + + +
          +
          + + + + + +
          +
          + + + + + +
          + + + + + + +
          + +
          + +
          + +
          +
          + + + + + + +
          + + +{{Subject .Subject "{{.InvitedBy}} has added you to the Grafana organization {{.OrgName}}"}} + + + + + +
          + + + + + + +
          +

          You have been added to the Grafana organization {{.OrgName}}

          +
          + +
          + + + + + +
          + + + + + +
          +

          You can switch organization in the left side menu, in the dropdown below your username.

          +
          + +
          + + + + + + + + + + + +
          +
          +
          + + diff --git a/public/emails/new_user_invite.html b/public/emails/new_user_invite.html new file mode 100644 index 00000000000..aeadbb98d7b --- /dev/null +++ b/public/emails/new_user_invite.html @@ -0,0 +1,204 @@ + + + + + + + + + + + + + +
          +
          + + + + + +
          +
          + + + + + +
          + + + + + + +
          + +
          + +
          + +
          +
          + + + + + + +
          + + +{{Subject .Subject "{{.InvitedBy}} has invited you to join Grafana"}} + + + + + +
          + + + + + + +
          +

          You're invited to sign up to Grafana and join organization {{.OrgName}}

          +
          + +
          + + + + + +
          + + + + + +
          + + + + +
          + Complete Sign Up +
          +
          + +
          + + + + + + + + + + + +
          +
          +
          + + diff --git a/public/emails/reset_password.html b/public/emails/reset_password.html index 56d2ecbd3af..3aed7f76c69 100644 --- a/public/emails/reset_password.html +++ b/public/emails/reset_password.html @@ -1,71 +1,203 @@ -{{Subject .Subject "Reset your Grafana password - {{.Name}}"}} - - - - - - - - - + + + + + +
          +
          - - - + + +
          + + + - -
          +
          -
          - -
          Grafnaa
          + + + - -
          -
          - + +
          + + + + + +
          + +
          -
          - - + +
          -

          Hi {{.Name}},

          +
          -

          Please click the following link to reset your password within {{.EmailCodeValidHours}} hours.

          -

          - {{.AppUrl}}user/password/reset?code={{.Code}} -

          -

          + +
          + + + + - - - -
          + {{Subject .Subject "Reset your Grafana password - {{.Name}}"}} + + + + + +
          + + + + + + +
          +

          Hi {{.Name}}

          +
          + +
          + + + + + +
          + + + + + +
          +

          + Please click the following link to reset your password within {{.EmailCodeValidHours}} hours. +

          +

          + {{.AppUrl}}user/password/reset?code={{.Code}} +

          +

          Not working? Try copying and pasting it to your browser.

          +
          + +
          -
          -

          - Not working? Try copying and pasting it to your browser.
          Otherwise, dont hesitate to email us at contact@grafana.org -

          -
          -
          - - - - -
          -

          © 2015 Grafana

          -
          + + + + + + + + +
          +
          +
          diff --git a/public/emails/welcome_on_signup.html b/public/emails/welcome_on_signup.html index 4b7ae73ef30..ed9df33f680 100644 --- a/public/emails/welcome_on_signup.html +++ b/public/emails/welcome_on_signup.html @@ -1,69 +1,197 @@ -{{Subject .Subject "Welcome to Grafana"}} + + + + + - - - - - - - {{.CheckType}} {{ .State }} for {{.Endpoint}}"}} - - + + + + + +
          +
          - - - - -
          + + + + +
          +
          -
          - -
          Grafana
          + + + - -
          -
          - - - - -
          + + + + + +
          + +
          -
          - - + +
          -

          Hi {{.Name}},

          +
          -

          - Thanks for signing up, we're happy to have you. -

          -

          - - - - -
          Log into your Grafana server
          -
          +
          +
          -
          - - - - -
          -

          © 2015 Grafana

          -
          + + + + + +
          + {{Subject .Subject "Welcome to Grafana"}} + + + + + +
          + + + + + + +
          +

          Hi {{.Name}}

          +
          + +
          + + + + + +
          + + + + + +
          +

          + If you are new to Grafana please read the Getting Started guide. +

          +
          +
          + + + + + + + + + + + +
          +
          +
          diff --git a/public/img/background_tease.jpg b/public/img/background_tease.jpg new file mode 100644 index 00000000000..043ad7f8976 Binary files /dev/null and b/public/img/background_tease.jpg differ diff --git a/public/test/test-main.js b/public/test/test-main.js index e6e18512216..946cc6aad77 100644 --- a/public/test/test-main.js +++ b/public/test/test-main.js @@ -22,6 +22,7 @@ require.config({ 'angular-sanitize': '../vendor/angular-sanitize/angular-sanitize', angularMocks: '../vendor/angular-mocks/angular-mocks', 'angular-dragdrop': '../vendor/angular-native-dragdrop/draganddrop', + 'angular-ui': '../vendor/angular-ui/angular-bootstrap', 'angular-strap': '../vendor/angular-other/angular-strap', timepicker: '../vendor/angular-other/timepicker', datepicker: '../vendor/angular-other/datepicker', @@ -83,6 +84,7 @@ require.config({ 'angular-route': ['angular'], 'angular-sanitize': ['angular'], + 'angular-ui': ['angular'], 'angular-dragdrop': ['jquery', 'angular'], 'angular-mocks': ['angular'], 'angular-strap': ['angular', 'bootstrap','timepicker', 'datepicker'], diff --git a/public/vendor/angular-ui/angular-bootstrap.js b/public/vendor/angular-ui/angular-bootstrap.js new file mode 100644 index 00000000000..f90955af4e7 --- /dev/null +++ b/public/vendor/angular-ui/angular-bootstrap.js @@ -0,0 +1,6 @@ +define([ + 'angular', + '../vendor/angular-ui/tabs', +], function() { +}); + diff --git a/public/vendor/angular-ui/tabs.js b/public/vendor/angular-ui/tabs.js new file mode 100644 index 00000000000..4d87ac0bab1 --- /dev/null +++ b/public/vendor/angular-ui/tabs.js @@ -0,0 +1,293 @@ + +/** + * @ngdoc overview + * @name ui.bootstrap.tabs + * + * @description + * AngularJS version of the tabs directive. + */ + +angular.module('ui.bootstrap.tabs', []) + +.controller('TabsetController', ['$scope', function TabsetCtrl($scope) { + var ctrl = this, + tabs = ctrl.tabs = $scope.tabs = []; + + ctrl.select = function(selectedTab) { + angular.forEach(tabs, function(tab) { + if (tab.active && tab !== selectedTab) { + tab.active = false; + tab.onDeselect(); + } + }); + selectedTab.active = true; + selectedTab.onSelect(); + }; + + ctrl.addTab = function addTab(tab) { + tabs.push(tab); + // we can't run the select function on the first tab + // since that would select it twice + if (tabs.length === 1 && tab.active !== false) { + tab.active = true; + } else if (tab.active) { + ctrl.select(tab); + } + else { + tab.active = false; + } + }; + + ctrl.removeTab = function removeTab(tab) { + var index = tabs.indexOf(tab); + //Select a new tab if the tab to be removed is selected and not destroyed + if (tab.active && tabs.length > 1 && !destroyed) { + //If this is the last tab, select the previous tab. else, the next tab. + var newActiveIndex = index == tabs.length - 1 ? index - 1 : index + 1; + ctrl.select(tabs[newActiveIndex]); + } + tabs.splice(index, 1); + }; + + var destroyed; + $scope.$on('$destroy', function() { + destroyed = true; + }); +}]) + +/** + * @ngdoc directive + * @name ui.bootstrap.tabs.directive:tabset + * @restrict EA + * + * @description + * Tabset is the outer container for the tabs directive + * + * @param {boolean=} vertical Whether or not to use vertical styling for the tabs. + * @param {boolean=} justified Whether or not to use justified styling for the tabs. + * + * @example + + + + First Content! + Second Content! + +
          + + First Vertical Content! + Second Vertical Content! + + + First Justified Content! + Second Justified Content! + +
          +
          + */ +.directive('tabset', function() { + return { + restrict: 'EA', + transclude: true, + replace: true, + scope: { + type: '@' + }, + controller: 'TabsetController', + templateUrl: 'app/partials/bootstrap/tabset.html', + link: function(scope, element, attrs) { + scope.vertical = angular.isDefined(attrs.vertical) ? scope.$parent.$eval(attrs.vertical) : false; + scope.justified = angular.isDefined(attrs.justified) ? scope.$parent.$eval(attrs.justified) : false; + } + }; +}) + +/** + * @ngdoc directive + * @name ui.bootstrap.tabs.directive:tab + * @restrict EA + * + * @param {string=} heading The visible heading, or title, of the tab. Set HTML headings with {@link ui.bootstrap.tabs.directive:tabHeading tabHeading}. + * @param {string=} select An expression to evaluate when the tab is selected. + * @param {boolean=} active A binding, telling whether or not this tab is selected. + * @param {boolean=} disabled A binding, telling whether or not this tab is disabled. + * + * @description + * Creates a tab with a heading and content. Must be placed within a {@link ui.bootstrap.tabs.directive:tabset tabset}. + * + * @example + + +
          + + +
          + + First Tab + + Alert me! + Second Tab, with alert callback and html heading! + + + {{item.content}} + + +
          +
          + + function TabsDemoCtrl($scope) { + $scope.items = [ + { title:"Dynamic Title 1", content:"Dynamic Item 0" }, + { title:"Dynamic Title 2", content:"Dynamic Item 1", disabled: true } + ]; + + $scope.alertMe = function() { + setTimeout(function() { + alert("You've selected the alert tab!"); + }); + }; + }; + +
          + */ + +/** + * @ngdoc directive + * @name ui.bootstrap.tabs.directive:tabHeading + * @restrict EA + * + * @description + * Creates an HTML heading for a {@link ui.bootstrap.tabs.directive:tab tab}. Must be placed as a child of a tab element. + * + * @example + + + + + HTML in my titles?! + And some content, too! + + + Icon heading?!? + That's right. + + + + + */ +.directive('tab', ['$parse', '$log', function($parse, $log) { + return { + require: '^tabset', + restrict: 'EA', + replace: true, + templateUrl: 'app/partials/bootstrap/tab.html', + transclude: true, + scope: { + active: '=?', + heading: '@', + onSelect: '&select', //This callback is called in contentHeadingTransclude + //once it inserts the tab's content into the dom + onDeselect: '&deselect' + }, + controller: function() { + //Empty controller so other directives can require being 'under' a tab + }, + compile: function(elm, attrs, transclude) { + return function postLink(scope, elm, attrs, tabsetCtrl) { + scope.$watch('active', function(active) { + if (active) { + tabsetCtrl.select(scope); + } + }); + + scope.disabled = false; + if ( attrs.disable ) { + scope.$parent.$watch($parse(attrs.disable), function(value) { + scope.disabled = !! value; + }); + } + + // Deprecation support of "disabled" parameter + // fix(tab): IE9 disabled attr renders grey text on enabled tab #2677 + // This code is duplicated from the lines above to make it easy to remove once + // the feature has been completely deprecated + if ( attrs.disabled ) { + $log.warn('Use of "disabled" attribute has been deprecated, please use "disable"'); + scope.$parent.$watch($parse(attrs.disabled), function(value) { + scope.disabled = !! value; + }); + } + + scope.select = function() { + if ( !scope.disabled ) { + scope.active = true; + } + }; + + tabsetCtrl.addTab(scope); + scope.$on('$destroy', function() { + tabsetCtrl.removeTab(scope); + }); + + //We need to transclude later, once the content container is ready. + //when this link happens, we're inside a tab heading. + scope.$transcludeFn = transclude; + }; + } + }; +}]) + +.directive('tabHeadingTransclude', [function() { + return { + restrict: 'A', + require: '^tab', + link: function(scope, elm, attrs, tabCtrl) { + scope.$watch('headingElement', function updateHeadingElement(heading) { + if (heading) { + elm.html(''); + elm.append(heading); + } + }); + } + }; +}]) + +.directive('tabContentTransclude', function() { + return { + restrict: 'A', + require: '^tabset', + link: function(scope, elm, attrs) { + var tab = scope.$eval(attrs.tabContentTransclude); + + //Now our tab is ready to be transcluded: both the tab heading area + //and the tab content area are loaded. Transclude 'em both. + tab.$transcludeFn(tab.$parent, function(contents) { + angular.forEach(contents, function(node) { + if (isTabHeading(node)) { + //Let tabHeadingTransclude know. + tab.headingElement = node; + } else { + elm.append(node); + } + }); + }); + } + }; + function isTabHeading(node) { + return node.tagName && ( + node.hasAttribute('tab-heading') || + node.hasAttribute('data-tab-heading') || + node.tagName.toLowerCase() === 'tab-heading' || + node.tagName.toLowerCase() === 'data-tab-heading' + ); + } +}) + +; diff --git a/public/vendor/bootstrap/less/bootstrap.less b/public/vendor/bootstrap/less/bootstrap.less index a22c4b24756..758dbde9848 100644 --- a/public/vendor/bootstrap/less/bootstrap.less +++ b/public/vendor/bootstrap/less/bootstrap.less @@ -22,7 +22,6 @@ // Base CSS @import "type.less"; -@import "code.less"; @import "forms.less"; @import "tables.less"; diff --git a/public/vendor/bootstrap/less/type.less b/public/vendor/bootstrap/less/type.less index 6a472db4979..8b137891791 100644 --- a/public/vendor/bootstrap/less/type.less +++ b/public/vendor/bootstrap/less/type.less @@ -1,247 +1 @@ -// -// Typography -// -------------------------------------------------- - -// Body text -// ------------------------- - -p { - margin: 0 0 @baseLineHeight / 2; -} -.lead { - margin-bottom: @baseLineHeight; - font-size: @baseFontSize * 1.5; - font-weight: 200; - line-height: @baseLineHeight * 1.5; -} - - -// Emphasis & misc -// ------------------------- - -// Ex: 14px base font * 85% = about 12px -small { font-size: 85%; } - -strong { font-weight: bold; } -em { font-style: italic; } -cite { font-style: normal; } - -// Utility classes -.muted { color: @grayLight; } -a.muted:hover, -a.muted:focus { color: darken(@grayLight, 10%); } - -.text-warning { color: @warningText; } -a.text-warning:hover, -a.text-warning:focus { color: darken(@warningText, 10%); } - -.text-error { color: @errorText; } -a.text-error:hover, -a.text-error:focus { color: darken(@errorText, 10%); } - -.text-info { color: @infoText; } -a.text-info:hover, -a.text-info:focus { color: darken(@infoText, 10%); } - -.text-success { color: @successText; } -a.text-success:hover, -a.text-success:focus { color: darken(@successText, 10%); } - -.text-left { text-align: left; } -.text-right { text-align: right; } -.text-center { text-align: center; } - - -// Headings -// ------------------------- - -h1, h2, h3, h4, h5, h6 { - margin: (@baseLineHeight / 2) 0; - font-family: @headingsFontFamily; - font-weight: @headingsFontWeight; - line-height: @baseLineHeight; - color: @headingsColor; - text-rendering: optimizelegibility; // Fix the character spacing for headings - small { - font-weight: normal; - line-height: 1; - color: @grayLight; - } -} - -h1, -h2, -h3 { line-height: @baseLineHeight * 2; } - -h1 { font-size: @baseFontSize * 2.75; } // ~38px -h2 { font-size: @baseFontSize * 2.25; } // ~32px -h3 { font-size: @baseFontSize * 1.75; } // ~24px -h4 { font-size: @baseFontSize * 1.25; } // ~18px -h5 { font-size: @baseFontSize; } -h6 { font-size: @baseFontSize * 0.85; } // ~12px - -h1 small { font-size: @baseFontSize * 1.75; } // ~24px -h2 small { font-size: @baseFontSize * 1.25; } // ~18px -h3 small { font-size: @baseFontSize; } -h4 small { font-size: @baseFontSize; } - - -// Page header -// ------------------------- - -.page-header { - padding-bottom: (@baseLineHeight / 2) - 1; - margin: @baseLineHeight 0 (@baseLineHeight * 1.5); - border-bottom: 1px solid @grayLighter; -} - - - -// Lists -// -------------------------------------------------- - -// Unordered and Ordered lists -ul, ol { - padding: 0; - margin: 0 0 @baseLineHeight / 2 25px; -} -ul ul, -ul ol, -ol ol, -ol ul { - margin-bottom: 0; -} -li { - line-height: @baseLineHeight; -} - -// Remove default list styles -ul.unstyled, -ol.unstyled { - margin-left: 0; - list-style: none; -} - -// Single-line list items -ul.inline, -ol.inline { - margin-left: 0; - list-style: none; - > li { - display: inline-block; - .ie7-inline-block(); - padding-left: 5px; - padding-right: 5px; - } -} - -// Description Lists -dl { - margin-bottom: @baseLineHeight; -} -dt, -dd { - line-height: @baseLineHeight; -} -dt { - font-weight: bold; -} -dd { - margin-left: @baseLineHeight / 2; -} -// Horizontal layout (like forms) -.dl-horizontal { - .clearfix(); // Ensure dl clears floats if empty dd elements present - dt { - float: left; - width: @horizontalComponentOffset - 20; - clear: left; - text-align: right; - .text-overflow(); - } - dd { - margin-left: @horizontalComponentOffset; - } -} - -// MISC -// ---- - -// Horizontal rules -hr { - margin: @baseLineHeight 0; - border: 0; - border-top: 1px solid @hrBorder; - border-bottom: 1px solid @white; -} - -// Abbreviations and acronyms -abbr[title], -// Added data-* attribute to help out our tooltip plugin, per https://github.com/twbs/bootstrap/issues/5257 -abbr[data-original-title] { - cursor: help; - border-bottom: 1px dotted @grayLight; -} -abbr.initialism { - font-size: 90%; - text-transform: uppercase; -} - -// Blockquotes -blockquote { - padding: 0 0 0 15px; - margin: 0 0 @baseLineHeight; - border-left: 5px solid @grayLighter; - p { - margin-bottom: 0; - font-size: @baseFontSize * 1.25; - font-weight: 300; - line-height: 1.25; - } - small { - display: block; - line-height: @baseLineHeight; - color: @grayLight; - &:before { - content: '\2014 \00A0'; - } - } - - // Float right with text-align: right - &.pull-right { - float: right; - padding-right: 15px; - padding-left: 0; - border-right: 5px solid @grayLighter; - border-left: 0; - p, - small { - text-align: right; - } - small { - &:before { - content: ''; - } - &:after { - content: '\00A0 \2014'; - } - } - } -} - -// Quotes -q:before, -q:after, -blockquote:before, -blockquote:after { - content: ""; -} - -// Addresses -address { - display: block; - margin-bottom: @baseLineHeight; - font-style: normal; - line-height: @baseLineHeight; -}