Commit 234d1291 by Torkel Ödegaard

Merge branch 'invite'

Conflicts:
	public/css/less/gfbox.less
	public/emails/reset_password.html
	public/emails/welcome_on_signup.html
parents 90169d6a 1ea0b537
# 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
......@@ -3,6 +3,7 @@ coverage/
.aws-config.json
awsconfig
/dist
/emails/dist
/tmp
docs/AWS_S3_BUCKET
......
- 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/
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;
}
}
default:
- 'clean'
- 'assemble'
- 'replace'
- 'uncss'
- 'processhtml'
- 'premailer'
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/'
}
};
};
module.exports = function(config) {
return {
dist: ['dist'],
};
};
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.
}],
}
};
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.
}],
}
};
module.exports = {
dist: {
overwrite: true,
src: ['dist/*.html'],
replacements: [{
from: '[[',
to: '{{'
}, {
from: ']]',
to: '}}'
}]
}
};
module.exports = {
dist: {
src: ['dist/*.html'],
dest: 'dist/css/tidy.css',
options: {
report: 'min' // optional: include to report savings
}
}
};
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,
}
}
};
module.exports = function(grunt) {
// load grunt config
require('load-grunt-config')(grunt);
};
{
"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"
}
}
<!-- This email is sent when an existing user is added to an organization -->
[[Subject .Subject "[[.InvitedBy]] has added you to the Grafana organization [[.OrgName]]"]]
<table class="row">
<tr>
<td class="wrapper last">
<table class="twelve columns">
<tr>
<td>
<h3>You have been added to the Grafana organization [[.OrgName]]</h3>
</td>
<td class="expander"></td>
</tr>
</table>
</td>
</tr>
</table>
<table class="row">
<tr>
<td class="wrapper last">
<table class="twelve columns">
<tr>
<td class="center">
<p>You can switch organization in the left side menu, in the dropdown below your username.</p>
</td>
<td class="expander"></td>
</tr>
</table>
</td>
</tr>
</table>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width"/>
<!-- build:css css/tidy.css -->
<link inline rel="stylesheet" href="../assets/css/ink.css">
<link inline rel="stylesheet" href="../assets/css/style.css">
<!-- /build -->
</head>
<body>
<table class="body">
<tr>
<td class="center" align="center" valign="top">
<center>
<table class="row header">
<tr>
<td class="center" align="center">
<center>
<table class="container">
<tr>
<td class="wrapper last">
<table class="twelve columns">
<tr>
<td class="six sub-columns center">
<img src="http://docs.grafana.org/img/logo_transparent_200x75.png" style="width: 150px; float: none; display: inline">
</td>
<td class="expander"></td>
</tr>
</table>
</td>
</tr>
</table>
</center>
</td>
</tr>
</table>
<table class="container">
<tr>
<td>
{{> body }}
<!-- footer -->
<table class="row footer">
<tr>
<td class="wrapper last">
<table class="twelve columns">
<tr>
<td align="center">
<center>
<p style="text-align:center;">
Sent by <a href="[[.AppUrl]]">Grafana v[[.BuildVersion]]</a>
</p>
</center>
</td>
<td class="expander"></td>
</tr>
</table>
</td>
</tr>
</table>
<!-- container end below -->
</td>
</tr>
</table>
</center>
</td>
</tr>
</table>
</body>
</html>
<!-- This email is sent when user who does not already exist in Grafana is added to an organization -->
[[Subject .Subject "[[.InvitedBy]] has invited you to join Grafana"]]
<table class="row">
<tr>
<td class="wrapper last">
<table class="twelve columns">
<tr>
<td>
<h3>You're invited to sign up to Grafana and join organization [[.OrgName]]</h3>
</td>
<td class="expander"></td>
</tr>
</table>
</td>
</tr>
</table>
<table class="row">
<tr>
<td class="wrapper last">
<table class="twelve columns">
<tr>
<td class="center">
<table class="button radius">
<tr>
<td>
<a href="[[.LinkUrl]]">Complete Sign Up</a>
</td>
</tr>
</table>
</td>
<td class="expander"></td>
</tr>
</table>
</td>
</tr>
</table>
[[Subject .Subject "Reset your Grafana password - [[.Name]]"]]
<table class="row">
<tr>
<td class="wrapper last">
<table class="twelve columns">
<tr>
<td>
<h3>Hi [[.Name]]</h3>
</td>
<td class="expander"></td>
</tr>
</table>
</td>
</tr>
</table>
<table class="row">
<tr>
<td class="wrapper last">
<table class="twelve columns">
<tr>
<td class="center">
<p>
Please click the following link to reset your password within <strong>[[.EmailCodeValidHours]] hours</strong>.
</p>
<p>
<a href="[[.AppUrl]]user/password/reset?code=[[.Code]]">[[.AppUrl]]user/password/reset?code=[[.Code]]</a>
</p>
<p>Not working? Try copying and pasting it to your browser.</p>
</td>
<td class="expander"></td>
</tr>
</table>
</td>
</tr>
</table>
[[Subject .Subject "Welcome to Grafana"]]
<table class="row">
<tr>
<td class="wrapper last">
<table class="twelve columns">
<tr>
<td>
<h3>Hi [[.Name]]</h3>
</td>
<td class="expander"></td>
</tr>
</table>
</td>
</tr>
</table>
<table class="row">
<tr>
<td class="wrapper last">
<table class="twelve columns">
<tr>
<td class="center">
<p>
If you are new to Grafana please read the <a href="http://docs.grafana.org/guides/gettingstarted/">Getting Started</a> guide.
</p>
</td>
<td class="expander"></td>
</tr>
</table>
</td>
</tr>
</table>
......@@ -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
......
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"`
}
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")
}
......@@ -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")
......
......@@ -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
......
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"`
}
......@@ -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)
......
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)
}
......@@ -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,
......
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
}
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)
})
})
})
}
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
}
......@@ -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'];
......
......@@ -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'],
......
......@@ -6,6 +6,7 @@ define([
'./inspectCtrl',
'./jsonEditorCtrl',
'./loginCtrl',
'./invitedCtrl',
'./resetPasswordCtrl',
'./sidemenuCtrl',
'./errorCtrl',
......
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();
});
});
......@@ -9,7 +9,7 @@
<div class="page-container">
<div class="page">
<h2>
User details
Edit User
</h2>
<form name="userForm">
......@@ -17,7 +17,7 @@
<div class="tight-form">
<ul class="tight-form-list">
<li class="tight-form-item" style="width: 100px">
<strong>Name</strong>
Name
</li>
<li>
<input type="text" required ng-model="user.name" class="input-xxlarge tight-form-input last" >
......@@ -28,7 +28,7 @@
<div class="tight-form">
<ul class="tight-form-list">
<li class="tight-form-item" style="width: 100px">
<strong>Email</strong>
Email
</li>
<li>
<input type="email" ng-model="user.email" class="input-xxlarge tight-form-input last" >
......@@ -39,7 +39,7 @@
<div class="tight-form">
<ul class="tight-form-list">
<li class="tight-form-item" style="width: 100px">
<strong>Username</strong>
Username
</li>
<li>
<input type="text" ng-model="user.login" class="input-xxlarge tight-form-input last" >
......@@ -53,16 +53,16 @@
<button type="submit" class="pull-right btn btn-success" ng-click="update()" ng-show="!createMode">Update</button>
</form>
<h2>
<h3>
Change password
</h2>
</h3>
<form name="passwordForm">
<div>
<div class="tight-form">
<ul class="tight-form-list">
<li class="tight-form-item" style="width: 100px">
<strong>New password</strong>
New password
</li>
<li>
<input type="password" required ng-minlength="4" ng-model="password" class="input-xxlarge tight-form-input last">
......@@ -76,9 +76,9 @@
<button type="submit" class="pull-right btn btn-success" ng-click="setPassword()">Update</button>
</form>
<h2>
<h3>
Permissions
</h2>
</h3>
<div>
<div class="tight-form last">
......@@ -97,9 +97,9 @@
<br>
</div>
<h2>
<h3>
Organizations
</h2>
</h3>
<form name="addOrgForm">
<div class="tight-form">
......
......@@ -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');
});
};
......
......@@ -3,6 +3,7 @@ define([
'./datasourceEditCtrl',
'./orgUsersCtrl',
'./newOrgCtrl',
'./userInviteCtrl',
'./orgApiKeysCtrl',
'./orgDetailsCtrl',
], function () {});
......@@ -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();
......
<div class="modal-body" ng-controller="UserInviteCtrl" ng-init="init()">
<a class="modal-close" ng-click="dismiss();">
<i class="fa fa-remove"></i>
</a>
<h3>
Invite Users
</h3>
<div class="modal-tagline">
Send invite or add existing Grafana users to the organization
<span class="highlight-word">{{contextSrv.user.orgName}}</span>
</div>
<br>
<br>
<form name="inviteForm">
<div style="display: inline-block">
<div>
<div class="tight-form" ng-repeat="invite in invites">
<ul class="tight-form-list">
<li class="tight-form-item">
Email or Username
</li>
<li>
<input type="text" ng-model="invite.loginOrEmail" required class="input-large tight-form-input" placeholder="email@test.com">
</li>
<li class="tight-form-item">
Name
</li>
<li>
<input type="text" ng-model="invite.name" class="input-large tight-form-input" placeholder="name (optional)">
</li>
<li class="tight-form-item">
Role
</li>
<li>
<select ng-model="invite.role" class="input-small tight-form-input" ng-options="f for f in ['Viewer', 'Editor', 'Read Only Editor', 'Admin']">
</select>
</li>
<li class="tight-form-item last" ng-show="$index > 0">
<a class="pointer" tabindex="1" ng-click="removeInvite(invite)">
<i class="fa fa-remove"></i>
</a>
</li>
<div class="clearfix"></div>
</ul>
</div>
</div>
<div style="text-align: left; margin-top: 6px;">
<a ng-click="addInvite()">+ Invite another</a>
<div class="form-inline" style="margin-top: 20px">
<editor-checkbox text="Skip sending invite email" model="options.skipEmails" change="targetBlur()"></editor-checkbox>
</div>
</div>
<div class="" style="margin-top: 30px; margin-bottom: 20px;">
<button type="button" class="btn btn-inverse" ng-click="dismiss()">Cancel</button>
<button type="submit" class="btn btn-success" ng-click="sendInvites();">Invite Users</button>
</div>
</div>
</form>
</div>
......@@ -9,53 +9,67 @@
<h2>Organization users</h2>
<form name="form">
<div class="tight-form">
<ul class="tight-form-list">
<li class="tight-form-item" style="width: 127px">
<strong>Username or Email</strong>
</li>
<li>
<input type="text" ng-model="user.loginOrEmail" required class="input-xlarge tight-form-input" placeholder="user@email.com or username">
</li>
<li class="tight-form-item">
role
</li>
<li>
<select type="text" ng-model="user.role" class="input-medium tight-form-input" ng-options="f for f in ['Viewer', 'Editor', 'Read Only Editor', 'Admin']">
</select>
</li>
<li>
<button class="btn btn-success tight-form-btn" ng-click="addUser()">Add</button>
</li>
<div class="clearfix"></div>
</ul>
</div>
</form>
<button class="btn btn-success pull-right" ng-click="openInviteModal()">
<i class="fa fa-plus"></i>
Add or Invite
</button>
<br>
<table class="grafana-options-table form-inline">
<tr>
<th>Login</th>
<th>Email</th>
<th>Role</th>
<th></th>
</tr>
<tr ng-repeat="user in users">
<td>{{user.login}}</td>
<td>{{user.email}}</td>
<td>
<select type="text" ng-model="user.role" class="input-medium" ng-options="f for f in ['Viewer', 'Editor', 'Read Only Editor', 'Admin']" ng-change="updateOrgUser(user)">
</select>
</td>
<td style="width: 1%">
<a ng-click="removeUser(user)" class="btn btn-danger btn-mini">
<i class="fa fa-remove"></i>
</a>
</td>
</tr>
</table>
<tabset>
<tab heading="Users ({{users.length}})">
<table class="grafana-options-table form-inline">
<tr>
<th>Login</th>
<th>Email</th>
<th>Role</th>
<th></th>
</tr>
<tr ng-repeat="user in users">
<td>{{user.login}}</td>
<td>{{user.email}}</td>
<td>
<select type="text" ng-model="user.role" class="input-medium" ng-options="f for f in ['Viewer', 'Editor', 'Read Only Editor', 'Admin']" ng-change="updateOrgUser(user)">
</select>
</td>
<td style="width: 1%">
<a ng-click="removeUser(user)" class="btn btn-danger btn-mini">
<i class="fa fa-remove"></i>
</a>
</td>
</tr>
</table>
</tab>
<tab heading="Pending Invitations ({{pendingInvites.length}})">
<div class="grafana-list-item" ng-repeat="invite in pendingInvites" ng-click="invite.expanded = !invite.expanded">
{{invite.email}}
<span ng-show="invite.name" style="padding-left: 20px"> {{invite.name}}</span>
<span class="pull-right">
<button class="btn btn-inverse btn-mini " data-clipboard-text="{{invite.url}}" clipboard-button ng-click="copyInviteToClipboard($event)"
<i class="fa fa-clipboard"></i> Copy Invite
</button>
&nbsp;
<a class="pointer">
<i ng-show="!invite.expanded" class="fa fa-caret-right"></i>
<i ng-show="invite.expanded" class="fa fa-caret-down"></i>
</a>
</span>
<div ng-show="invite.expanded">
<a href="{{invite.url}}">{{invite.url}}</a><br>
<button class="btn btn-inverse btn-mini">
<i class="fa fa-envelope-o"></i> Resend invite
</button>
&nbsp;
<button class="btn btn-inverse btn-mini" ng-click="revokeInvite(invite, $event)">
<i class="fa fa-remove" style="color: red"></i> Revoke invite
</button>
<span style="padding-left: 15px">
Invited: <em> {{invite.createdOn | date: 'shortDate'}} by {{invite.invitedBy}} </em>
</span>
<div>
</div>
</tab>
</tabset>
</div>
</div>
......
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);
}
});
};
});
});
......@@ -8,7 +8,7 @@
<div class="page-container">
<div class="page">
<h2>Profile details</h2>
<h2>Profile</h2>
<form name="userForm">
<div>
......@@ -64,7 +64,7 @@
<button type="submit" class="pull-right btn btn-success" ng-click="update()">Update</button>
</form>
<h2>Organizations</h2>
<h3>Organizations</h3>
<table class="grafana-options-table">
<tr ng-repeat="org in orgs">
......
<li ng-class="{active: active, disabled: disabled}">
<a href ng-click="select()" tab-heading-transclude>{{heading}}</a>
</li>
<div>
<ul class="nav nav-{{type || 'tabs'}} nav-tabs-alt" ng-class="{'nav-stacked': vertical, 'nav-justified': justified}" ng-transclude></ul>
<div class="tab-content">
<div class="tab-pane"
ng-repeat="tab in tabs"
ng-class="{active: tab.active}"
tab-content-transclude="tab">
</div>
</div>
</div>
......@@ -12,7 +12,7 @@
{{title}}
</div>
<div class="confirm-modal-text">
<div class="modal-tagline">
{{text}}
</div>
......
<div class="container">
<div class="login-page-background">
</div>
<div class="login-box">
......
<div class="container">
<div class="login-page-background">
</div>
<div class="login-box">
<div class="login-box-logo">
<img src="img/logo_transparent_200x75.png">
</div>
<div class="invite-box">
<h3>
Hello {{greeting}}.
</h3>
<div class="modal-tagline">
<em>{{invitedBy}}</em> has invited you to join Grafana and the organization <span class="highlight-word">{{contextSrv.user.orgName}}</span></br>Please complete the following to accept your invitation and continue:
</div>
<form name="inviteForm" class="login-form">
<div class="tight-from-container">
<div class="tight-form">
<ul class="tight-form-list">
<li class="tight-form-item" style="width: 128px">
Email
</li>
<li>
<input type="email" name="email" class="tight-form-input last" required ng-model='formModel.email' placeholder="Email" style="width: 253px">
</li>
</ul>
<div class="clearfix"></div>
</div>
<div class="tight-form">
<ul class="tight-form-list">
<li class="tight-form-item" style="width: 128px">
Name
</li>
<li>
<input type="text" name="name" class="tight-form-input last" ng-model='formModel.name' placeholder="Name (optional)" style="width: 253px">
</li>
</ul>
<div class="clearfix"></div>
</div>
<div class="tight-form">
<ul class="tight-form-list">
<li class="tight-form-item" style="width: 128px">
Username
</li>
<li>
<input type="text" name="username" class="tight-form-input last" required ng-model='formModel.username' placeholder="Username" style="width: 253px">
</li>
</ul>
<div class="clearfix"></div>
</div>
<div class="tight-form">
<ul class="tight-form-list">
<li class="tight-form-item" style="width: 128px">
Password
</li>
<li>
<input type="password" name="password" class="tight-form-input last" required ng-model="formModel.password" id="inputPassword" style="width: 253px" placeholder="password">
</li>
</ul>
<div class="clearfix"></div>
</div>
</div>
<div style="margin-left: 147px; width: 254px;">
<password-strength password="formModel.password"></password-strength>
</div>
<div class="login-submit-button-row">
<button type="submit" class="btn" ng-click="submit();" ng-class="{'btn-inverse': !inviteForm.$valid, 'btn-primary': inviteForm.$valid}">
Continue
</button>
</div>
</form>
<div class="clearfix"></div>
</div>
<div class="row" style="margin-top: 50px">
<div class="version-footer text-center small">
Grafana version: {{buildInfo.version}}, commit: {{buildInfo.commit}},
build date: {{buildInfo.buildstamp | date: 'yyyy-MM-dd HH:mm:ss' }}
</div>
</div>
</div>
</div>
......@@ -97,6 +97,10 @@ define([
templateUrl: 'app/partials/login.html',
controller : 'LoginCtrl',
})
.when('/invite/:code', {
templateUrl: 'app/partials/signup_invited.html',
controller : 'InvitedCtrl',
})
.when('/user/password/send-reset-email', {
templateUrl: 'app/partials/reset_password.html',
controller : 'ResetPasswordCtrl',
......
......@@ -71,7 +71,7 @@ function (angular, _) {
var confirmModal = $modal({
template: './app/partials/confirm_modal.html',
persist: false,
modalClass: 'confirm-modal',
modalClass: 'modal-no-header confirm-modal',
show: false,
scope: scope,
keyboard: false
......
......@@ -14,6 +14,7 @@ function (angular) {
this.showModal = function(e, options) {
var modal = $modal({
modalClass: options.modalClass,
template: options.src,
persist: false,
show: false,
......
......@@ -220,6 +220,7 @@ div.subnav {
li > a:hover,
li.active > a,
li.active > a:focus,
li.active > a:hover {
border-color: transparent;
background-color: transparent;
......@@ -547,8 +548,6 @@ a:hover {
}
.modal {
.border-radius(1px);
border-top: solid 1px lighten(@grayDark, 5%);
background-color: @grafanaPanelBackground;
}
......
......@@ -159,6 +159,7 @@ div.subnav {
li > a:hover,
li.active > a,
li.active > a:focus,
li.active > a:hover {
border-color: transparent;
background-color: transparent;
......@@ -531,19 +532,6 @@ a.thumbnail {
.border-radius(0);
}
.modal {
.border-radius(0);
background-color: @bodyBackground;
&-header {
border-bottom: none;
}
&-footer {
border-top: none;
background-color: transparent;
}
}
.popover {
.border-radius(0);
......
......@@ -67,12 +67,6 @@
position: relative;
border: @grafanaPanelBorder;
padding: 20px 20px 60px 49px;
h2 {
color: @textColor;
font-weight: normal;
font-size: 22px;
}
}
.page {
......
@import "type.less";
@import "login.less";
@import "submenu.less";
@import "graph.less";
......@@ -15,6 +16,7 @@
@import "admin.less";
@import "validation.less";
@import "fonts.less";
@import "tabs.less";
.row-control-inner {
padding:0px;
......@@ -257,18 +259,28 @@
td:first-child { text-align: right; }
}
.confirm-modal {
.modal-no-header {
border: 1px solid @grafanaTargetFuncBackground;
max-width: 500px;
background-color: @grafanaPanelBackground;
text-align: center;
h3 {
margin-top: 30px;
}
.modal-close {
float: right;
font-size: 140%;
padding: 10px;
}
.modal-tagline {
font-size: 16px;
}
}
.confirm-modal {
max-width: 500px;
.confirm-modal-icon {
padding-top: 41px;
font-size: 280%;
......@@ -282,10 +294,6 @@
margin-bottom: 15px;
}
.confirm-modal-text {
font-size: 16px;
}
.confirm-modal-buttons {
margin-top: 35px;
margin-bottom: 35px;
......@@ -354,5 +362,27 @@
color: @orange;
}
.highlight-word {
color: @orange;
}
.body-copy-emphasis {
color: @headingsColor;
}
.signup-page-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100%;
width: 100%;
background-image: url(/img/background_tease.jpg);
.signup-logo-container {
width: 150px;
margin: 0 auto;
padding: 80px 0;
}
}
......@@ -93,4 +93,46 @@
}
}
.login-page-background {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100%;
width: 100%;
background-image: url(/img/background_tease.jpg);
opacity: 0.05;
z-index: -1;
}
.invite-box {
text-align: center;
border: 1px solid @grafanaTargetFuncBackground;
background-color: @grafanaPanelBackground;
position: fixed;
max-width: 800px;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
top: 20%;
.tight-form {
text-align: left;
}
h3 {
margin-top: 30px;
}
.modal-close {
float: right;
font-size: 140%;
padding: 10px;
}
.modal-tagline {
font-size: 16px;
}
}
......@@ -33,23 +33,11 @@
white-space: nowrap;
}
.grafana-options-list {
list-style: none;
margin: 0;
max-width: 450px;
li:nth-child(odd) {
background-color: @grafanaListAccent;
}
li {
float: left;
margin: 2px;
padding: 5px 10px;
border: 1px solid @grafanaListBorderBottom;
border: 1px solid @grafanaListBorderBottom;
}
li:first-child {
border: 1px solid @grafanaListBorderBottom;
}
.grafana-list-item {
display: block;
padding: 1px 10px;
line-height: 34px;
background-color: @grafanaTargetBackground;
margin-bottom: 4px;
cursor: pointer;
}
.nav-tabs-alt {
border-bottom: @grafanaTriggerBorder;
padding-left: 10px;
& > li > a {
color: darken(@linkColor, 20%);
}
li > a:hover {
border-bottom: none;
}
li.active > a,
li.active > a:focus,
li.active > a:hover {
.border-radius(3px);
border: @grafanaTriggerBorder;
background-color: transparent;
border-bottom: 1px solid @grafanaPanelBackground;
color: @linkColor;
}
li.disabled > a {
color: @textColor;
}
.open .dropdown-toggle {
background-color: #060606;
border-color: transparent;
}
}
......@@ -165,7 +165,7 @@ select.tight-form-input {
margin: 0px;
border-radius: 0;
height: 36px;
padding: 8px 3px;
padding: 9px 3px;
&.last {
border-right: none;
}
......
//
// 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: 500; }
em { font-style: italic; color: @headingsColor; }
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.00; } // ~38px
h2 { font-size: @baseFontSize * 1.75; } // ~32px
h3 { font-size: @baseFontSize * 1.50; } // ~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;
}
......@@ -69,8 +69,8 @@
@altFontFamily: @serifFontFamily;
@headingsFontFamily: inherit; // empty to use BS default, @baseFontFamily
@headingsFontWeight: bold; // instead of browser default, bold
@headingsColor: @textColor; // empty to use BS default, @textColor
@headingsFontWeight: normal; // instead of browser default, bold
@headingsColor: darken(@white,11%); // empty to use BS default, @textColor
@inputText: @black;
......@@ -98,6 +98,8 @@
@grafanaListHighlight: #333;
@grafanaListMainLinkColor: @textColor;
@pageContainerBorderColor: @grayDark;
// Scrollbars
@scrollbarBackground: #3a3a3a;
@scrollbarBackground2: #3a3a3a;
......
......@@ -33,9 +33,9 @@
// grafana Variables
// -------------------------
@grafanaPanelBackground: @white;
@grafanaPanelBorder: solid 1px #ddd;
@grafanaTriggerBorder: solid 1px @grayLighter;
@grafanaPanelBackground: @grayLighter;
@grafanaPanelBorder: solid 1px #ddd;
@grafanaTriggerBorder: solid 1px @grayLight;
// Submenu
@submenuBackground: rgb(218, 217, 217);
......@@ -58,16 +58,14 @@
// Scaffolding
// -------------------------
@bodyBackground: #EAEAEA;
@bodyBackground: #EFEFEF;
@textColor: @gray;
// Links
// -------------------------
@linkColor: @gray;
@linkColorDisabled: lighten(@linkColor,30%);
@linkColorHover: @grayDarker;
@linkColorHover: darken(@linkColor, 20%);
// Typography
// -------------------------
......@@ -76,14 +74,14 @@
@monoFontFamily: Menlo, Monaco, Consolas, "Courier New", monospace;
@baseFontSize: 14px;
@baseFontWeight: 400;
@baseFontWeight: 400;
@baseFontFamily: @sansFontFamily;
@baseLineHeight: 20px;
@altFontFamily: @serifFontFamily;
@headingsFontFamily: inherit; // empty to use BS default, @baseFontFamily
@headingsFontWeight: bold; // instead of browser default, bold
@headingsColor: @grayDarker; // empty to use BS default, @textColor
@headingsFontWeight: normal; // instead of browser default, bold
@headingsColor: @textColor; // empty to use BS default, @textColor
// Component sizing
......@@ -111,6 +109,7 @@
@grafanaListHighlightContrast: #ddd;
@grafanaListMainLinkColor: @textColor;
@pageContainerBorderColor: darken(@grafanaTargetBackground, 5%);
// Tables
......
html files in this folder are generated from templates and build system in repo_root/emails
......@@ -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'],
......
define([
'angular',
'../vendor/angular-ui/tabs',
], function() {
});
/**
* @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
<example module="ui.bootstrap">
<file name="index.html">
<tabset>
<tab heading="Tab 1"><b>First</b> Content!</tab>
<tab heading="Tab 2"><i>Second</i> Content!</tab>
</tabset>
<hr />
<tabset vertical="true">
<tab heading="Vertical Tab 1"><b>First</b> Vertical Content!</tab>
<tab heading="Vertical Tab 2"><i>Second</i> Vertical Content!</tab>
</tabset>
<tabset justified="true">
<tab heading="Justified Tab 1"><b>First</b> Justified Content!</tab>
<tab heading="Justified Tab 2"><i>Second</i> Justified Content!</tab>
</tabset>
</file>
</example>
*/
.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
<example module="ui.bootstrap">
<file name="index.html">
<div ng-controller="TabsDemoCtrl">
<button class="btn btn-small" ng-click="items[0].active = true">
Select item 1, using active binding
</button>
<button class="btn btn-small" ng-click="items[1].disabled = !items[1].disabled">
Enable/disable item 2, using disabled binding
</button>
<br />
<tabset>
<tab heading="Tab 1">First Tab</tab>
<tab select="alertMe()">
<tab-heading><i class="icon-bell"></i> Alert me!</tab-heading>
Second Tab, with alert callback and html heading!
</tab>
<tab ng-repeat="item in items"
heading="{{item.title}}"
disabled="item.disabled"
active="item.active">
{{item.content}}
</tab>
</tabset>
</div>
</file>
<file name="script.js">
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!");
});
};
};
</file>
</example>
*/
/**
* @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
<example module="ui.bootstrap">
<file name="index.html">
<tabset>
<tab>
<tab-heading><b>HTML</b> in my titles?!</tab-heading>
And some content, too!
</tab>
<tab>
<tab-heading><i class="icon-heart"></i> Icon heading?!?</tab-heading>
That's right.
</tab>
</tabset>
</file>
</example>
*/
.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'
);
}
})
;
......@@ -22,7 +22,6 @@
// Base CSS
@import "type.less";
@import "code.less";
@import "forms.less";
@import "tables.less";
......
//
// 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;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment