frontend, api, db: add single sign-on
Closes https://gitlab.com/commento/commento/issues/90
This commit is contained in:
parent
536ec14b93
commit
1d1cd46c2b
@ -173,6 +173,7 @@ func commentListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"twitter": twitterConfigured && d.TwitterProvider,
|
||||
"github": githubConfigured && d.GithubProvider,
|
||||
"gitlab": gitlabConfigured && d.GitlabProvider,
|
||||
"sso": d.SsoProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -22,4 +22,7 @@ type domain struct {
|
||||
TwitterProvider bool `json:"twitterProvider"`
|
||||
GithubProvider bool `json:"githubProvider"`
|
||||
GitlabProvider bool `json:"gitlabProvider"`
|
||||
SsoProvider bool `json:"ssoProvider"`
|
||||
SsoSecret string `json:"ssoSecret"`
|
||||
SsoUrl string `json:"ssoUrl"`
|
||||
}
|
||||
|
@ -24,7 +24,10 @@ func domainGet(dmn string) (domain, error) {
|
||||
googleProvider,
|
||||
twitterProvider,
|
||||
githubProvider,
|
||||
gitlabProvider
|
||||
gitlabProvider,
|
||||
ssoProvider,
|
||||
ssoSecret,
|
||||
ssoUrl
|
||||
FROM domains
|
||||
WHERE domain = $1;
|
||||
`
|
||||
@ -48,7 +51,10 @@ func domainGet(dmn string) (domain, error) {
|
||||
&d.GoogleProvider,
|
||||
&d.TwitterProvider,
|
||||
&d.GithubProvider,
|
||||
&d.GitlabProvider); err != nil {
|
||||
&d.GitlabProvider,
|
||||
&d.SsoProvider,
|
||||
&d.SsoSecret,
|
||||
&d.SsoUrl); err != nil {
|
||||
return d, errorNoSuchDomain
|
||||
}
|
||||
|
||||
|
@ -26,7 +26,10 @@ func domainList(ownerHex string) ([]domain, error) {
|
||||
googleProvider,
|
||||
twitterProvider,
|
||||
githubProvider,
|
||||
gitlabProvider
|
||||
gitlabProvider,
|
||||
ssoProvider,
|
||||
ssoSecret,
|
||||
ssoUrl
|
||||
FROM domains
|
||||
WHERE ownerHex=$1;
|
||||
`
|
||||
@ -56,7 +59,10 @@ func domainList(ownerHex string) ([]domain, error) {
|
||||
&d.GoogleProvider,
|
||||
&d.TwitterProvider,
|
||||
&d.GithubProvider,
|
||||
&d.GitlabProvider); err != nil {
|
||||
&d.GitlabProvider,
|
||||
&d.SsoProvider,
|
||||
&d.SsoSecret,
|
||||
&d.SsoUrl); err != nil {
|
||||
logger.Errorf("cannot Scan domain: %v", err)
|
||||
return nil, errorInternal
|
||||
}
|
||||
|
69
api/domain_sso.go
Normal file
69
api/domain_sso.go
Normal file
@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func domainSsoNew(domain string) (string, error) {
|
||||
if domain == "" {
|
||||
return "", errorMissingField
|
||||
}
|
||||
|
||||
ssoSecret, err := randomHex(32)
|
||||
if err != nil {
|
||||
logger.Errorf("error generating SSO secret hex: %v", err)
|
||||
return "", errorInternal
|
||||
}
|
||||
|
||||
statement := `
|
||||
UPDATE domains
|
||||
SET ssoSecret = $2
|
||||
WHERE domain = $1;
|
||||
`
|
||||
_, err = db.Exec(statement, domain, ssoSecret)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot update ssoSecret: %v", err)
|
||||
return "", errorInternal
|
||||
}
|
||||
|
||||
return ssoSecret, nil
|
||||
}
|
||||
|
||||
func domainSsoNewHandler(w http.ResponseWriter, r *http.Request) {
|
||||
type request struct {
|
||||
OwnerToken *string `json:"ownerToken"`
|
||||
Domain *string `json:"domain"`
|
||||
}
|
||||
|
||||
var x request
|
||||
if err := bodyUnmarshal(r, &x); err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
o, err := ownerGetByOwnerToken(*x.OwnerToken)
|
||||
if err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
domain := domainStrip(*x.Domain)
|
||||
isOwner, err := domainOwnershipVerify(o.OwnerHex, domain)
|
||||
if err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if !isOwner {
|
||||
bodyMarshal(w, response{"success": false, "message": errorNotAuthorised.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ssoSecret, err := domainSsoNew(domain)
|
||||
if err != nil {
|
||||
bodyMarshal(w, response{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{"success": true, "ssoSecret": ssoSecret})
|
||||
}
|
@ -5,6 +5,10 @@ import (
|
||||
)
|
||||
|
||||
func domainUpdate(d domain) error {
|
||||
if d.SsoProvider && d.SsoUrl == "" {
|
||||
return errorMissingField
|
||||
}
|
||||
|
||||
statement := `
|
||||
UPDATE domains
|
||||
SET
|
||||
@ -19,7 +23,9 @@ func domainUpdate(d domain) error {
|
||||
googleProvider=$10,
|
||||
twitterProvider=$11,
|
||||
githubProvider=$12,
|
||||
gitlabProvider=$13
|
||||
gitlabProvider=$13,
|
||||
ssoProvider=$14,
|
||||
ssoUrl=$15
|
||||
WHERE domain=$1;
|
||||
`
|
||||
|
||||
@ -36,7 +42,9 @@ func domainUpdate(d domain) error {
|
||||
d.GoogleProvider,
|
||||
d.TwitterProvider,
|
||||
d.GithubProvider,
|
||||
d.GitlabProvider)
|
||||
d.GitlabProvider,
|
||||
d.SsoProvider,
|
||||
d.SsoUrl)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot update non-moderators: %v", err)
|
||||
return errorInternal
|
||||
|
10
api/oauth_sso.go
Normal file
10
api/oauth_sso.go
Normal file
@ -0,0 +1,10 @@
|
||||
package main
|
||||
|
||||
type ssoPayload struct {
|
||||
Domain string `json:"domain"`
|
||||
Token string `json:"token"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
Photo string `json:"photo"`
|
||||
}
|
110
api/oauth_sso_callback.go
Normal file
110
api/oauth_sso_callback.go
Normal file
@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func ssoCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
payloadHex := r.FormValue("payload")
|
||||
signature := r.FormValue("hmac")
|
||||
|
||||
payloadBytes, err := hex.DecodeString(payloadHex)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: invalid JSON payload hex encoding: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
signatureBytes, err := hex.DecodeString(signature)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: invalid HMAC signature hex encoding: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
payload := ssoPayload{}
|
||||
err = json.Unmarshal(payloadBytes, &payload)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: cannot unmarshal JSON payload: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Domain == "" || payload.Token == "" || payload.Email == "" || payload.Name == "" {
|
||||
fmt.Fprintf(w, "Error: %s\n", errorMissingField.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Link == "" {
|
||||
payload.Link = "undefined"
|
||||
}
|
||||
|
||||
if payload.Photo == "" {
|
||||
payload.Photo = "undefined"
|
||||
}
|
||||
|
||||
d, err := domainGet(payload.Domain)
|
||||
if err != nil {
|
||||
if err == errorNoSuchDomain {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
} else {
|
||||
logger.Errorf("cannot get domain for SSO: %v", err)
|
||||
fmt.Fprintf(w, "Error: %s\n", errorInternal.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if d.SsoSecret == "" || d.SsoUrl == "" {
|
||||
fmt.Fprintf(w, "Error: %s\n", errorMissingConfig.Error())
|
||||
return
|
||||
}
|
||||
|
||||
key, err := hex.DecodeString(d.SsoSecret)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot decode SSO secret as hex: %v", err)
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write(payloadBytes)
|
||||
expectedSignatureBytes := h.Sum(nil)
|
||||
if !hmac.Equal(expectedSignatureBytes, signatureBytes) {
|
||||
fmt.Fprintf(w, "Error: HMAC signature verification failed\n")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = commenterGetByCommenterToken(payload.Token)
|
||||
if err != nil && err != errorNoSuchToken {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c, err := commenterGetByEmail("sso:"+d.Domain, payload.Email)
|
||||
if err != nil && err != errorNoSuchCommenter {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var commenterHex string
|
||||
|
||||
// TODO: in case of returning users, update the information we have on record?
|
||||
if err == errorNoSuchCommenter {
|
||||
commenterHex, err = commenterNew(payload.Email, payload.Name, payload.Link, payload.Photo, "sso:"+d.Domain, "")
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
commenterHex = c.CommenterHex
|
||||
}
|
||||
|
||||
if err = commenterSessionUpdate(payload.Token, commenterHex); err != nil {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "<html><script>window.parent.close()</script></html>")
|
||||
}
|
82
api/oauth_sso_redirect.go
Normal file
82
api/oauth_sso_redirect.go
Normal file
@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func ssoRedirectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
commenterToken := r.FormValue("commenterToken")
|
||||
domain := r.Header.Get("Referer")
|
||||
|
||||
if commenterToken == "" {
|
||||
fmt.Fprintf(w, "Error: %s\n", errorMissingField.Error())
|
||||
return
|
||||
}
|
||||
|
||||
domain = domainStrip(domain)
|
||||
if domain == "" {
|
||||
fmt.Fprintf(w, "Error: No Referer header found in request\n")
|
||||
return
|
||||
}
|
||||
|
||||
_, err := commenterGetByCommenterToken(commenterToken)
|
||||
if err != nil && err != errorNoSuchToken {
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
d, err := domainGet(domain)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Error: %s\n", errorNoSuchDomain.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !d.SsoProvider {
|
||||
fmt.Fprintf(w, "Error: SSO not configured for %s\n", domain)
|
||||
return
|
||||
}
|
||||
|
||||
if d.SsoSecret == "" || d.SsoUrl == "" {
|
||||
fmt.Fprintf(w, "Error: %s\n", errorMissingConfig.Error())
|
||||
return
|
||||
}
|
||||
|
||||
key, err := hex.DecodeString(d.SsoSecret)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot decode SSO secret as hex: %v", err)
|
||||
fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tokenBytes, err := hex.DecodeString(commenterToken)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot decode hex commenterToken: %v", err)
|
||||
fmt.Fprintf(w, "Error: %s\n", errorInternal.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write(tokenBytes)
|
||||
signature := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
u, err := url.Parse(d.SsoUrl)
|
||||
if err != nil {
|
||||
// this should really not be happening; we're checking if the
|
||||
// passed URL is valid at domain update
|
||||
logger.Errorf("cannot parse URL: %v", err)
|
||||
fmt.Fprintf(w, "Error: %s\n", errorInternal.Error())
|
||||
return
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Set("token", commenterToken)
|
||||
q.Set("hmac", signature)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
http.Redirect(w, r, u.String(), http.StatusFound)
|
||||
}
|
@ -15,6 +15,7 @@ func apiRouterInit(router *mux.Router) error {
|
||||
router.HandleFunc("/api/domain/new", domainNewHandler).Methods("POST")
|
||||
router.HandleFunc("/api/domain/delete", domainDeleteHandler).Methods("POST")
|
||||
router.HandleFunc("/api/domain/clear", domainClearHandler).Methods("POST")
|
||||
router.HandleFunc("/api/domain/sso/new", domainSsoNewHandler).Methods("POST")
|
||||
router.HandleFunc("/api/domain/list", domainListHandler).Methods("POST")
|
||||
router.HandleFunc("/api/domain/update", domainUpdateHandler).Methods("POST")
|
||||
router.HandleFunc("/api/domain/moderator/new", domainModeratorNewHandler).Methods("POST")
|
||||
@ -46,6 +47,9 @@ func apiRouterInit(router *mux.Router) error {
|
||||
router.HandleFunc("/api/oauth/gitlab/redirect", gitlabRedirectHandler).Methods("GET")
|
||||
router.HandleFunc("/api/oauth/gitlab/callback", gitlabCallbackHandler).Methods("GET")
|
||||
|
||||
router.HandleFunc("/api/oauth/sso/redirect", ssoRedirectHandler).Methods("GET")
|
||||
router.HandleFunc("/api/oauth/sso/callback", ssoCallbackHandler).Methods("GET")
|
||||
|
||||
router.HandleFunc("/api/comment/new", commentNewHandler).Methods("POST")
|
||||
router.HandleFunc("/api/comment/list", commentListHandler).Methods("POST")
|
||||
router.HandleFunc("/api/comment/count", commentCountHandler).Methods("POST")
|
||||
|
10
db/20190420181913-sso.sql
Normal file
10
db/20190420181913-sso.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- Single Sign-On (SSO)
|
||||
|
||||
ALTER TABLE domains
|
||||
ADD ssoProvider BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE domains
|
||||
ADD ssoSecret TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE domains
|
||||
ADD ssoUrl TEXT NOT NULL DEFAULT '';
|
@ -292,6 +292,29 @@
|
||||
<label for="gitlab-provider">GitLab login</label>
|
||||
</div>
|
||||
|
||||
<div class="row no-border commento-round-check">
|
||||
<input type="checkbox" v-model="domains[cd].ssoProvider" id="sso-provider" @change="window.commento.ssoProviderChangeHandler()">
|
||||
<label for="sso-provider">Single sign-on</label>
|
||||
</div>
|
||||
|
||||
<div class="indent" v-if="domains[cd].ssoProvider">
|
||||
<div class="row">
|
||||
<div class="label">HMAC shared secret key</div>
|
||||
<input class="input gray-input monospace" id="sso-secret" readonly="true" type="text" placeholder="Loading..." v-model="domains[cd].ssoSecret">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Redirect URL</div>
|
||||
<input class="input gray-input" id="sso-url" type="text" :placeholder="domains[cd].ssoUrl" v-model="domains[cd].ssoUrl">
|
||||
</div>
|
||||
<div class="normal-text">
|
||||
<div class="subtext-container">
|
||||
<div class="subtext">
|
||||
Read the Commento documentation <a href="https://docs.commento.io/configuration/frontend/sso.html">on single sign-on</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="warning" v-if="!domains[cd].allowAnonymous && !domains[cd].commentoProvider && !domains[cd].googleProvider && !domains[cd].twitterProvider && !domains[cd].githubProvider && !domains[cd].gitlabProvider">
|
||||
You have disabled all authentication options. Your readers will not be able to login, create comments, or vote.
|
||||
</div>
|
||||
|
@ -1214,10 +1214,14 @@
|
||||
global.popupRender = function(id) {
|
||||
var loginBoxContainer = $(ID_LOGIN_BOX_CONTAINER);
|
||||
var loginBox = create("div");
|
||||
var ssoSubtitle = create("div");
|
||||
var ssoButtonContainer = create("div");
|
||||
var ssoButton = create("div");
|
||||
var hr1 = create("hr");
|
||||
var oauthSubtitle = create("div");
|
||||
var oauthButtonsContainer = create("div");
|
||||
var oauthButtons = create("div");
|
||||
var hr = create("hr");
|
||||
var hr2 = create("hr");
|
||||
var emailSubtitle = create("div");
|
||||
var emailContainer = create("div");
|
||||
var email = create("div");
|
||||
@ -1233,7 +1237,7 @@
|
||||
emailButton.id = ID_LOGIN_BOX_EMAIL_BUTTON;
|
||||
loginLink.id = ID_LOGIN_BOX_LOGIN_LINK;
|
||||
loginLinkContainer.id = ID_LOGIN_BOX_LOGIN_LINK_CONTAINER;
|
||||
hr.id = ID_LOGIN_BOX_HR;
|
||||
hr2.id = ID_LOGIN_BOX_HR;
|
||||
oauthSubtitle.id = ID_LOGIN_BOX_OAUTH_PRETEXT;
|
||||
oauthButtonsContainer.id = ID_LOGIN_BOX_OAUTH_BUTTONS_CONTAINER;
|
||||
|
||||
@ -1246,6 +1250,9 @@
|
||||
classAdd(emailButton, "email-button");
|
||||
classAdd(loginLinkContainer, "login-link-container");
|
||||
classAdd(loginLink, "login-link");
|
||||
classAdd(ssoSubtitle, "login-box-subtitle");
|
||||
classAdd(ssoButtonContainer, "oauth-buttons-container");
|
||||
classAdd(ssoButton, "oauth-buttons");
|
||||
classAdd(oauthSubtitle, "login-box-subtitle");
|
||||
classAdd(oauthButtonsContainer, "oauth-buttons-container");
|
||||
classAdd(oauthButtons, "oauth-buttons");
|
||||
@ -1256,6 +1263,7 @@
|
||||
emailSubtitle.innerText = "Login with your email address";
|
||||
emailButton.innerText = "Continue";
|
||||
oauthSubtitle.innerText = "Proceed with social login";
|
||||
ssoSubtitle.innerText = "Proceed with " + parent.location.host + " authentication";
|
||||
|
||||
onclick(emailButton, global.passwordAsk, id);
|
||||
onclick(loginLink, global.popupSwitch);
|
||||
@ -1285,6 +1293,26 @@
|
||||
}
|
||||
});
|
||||
|
||||
if (configuredOauths["sso"]) {
|
||||
var button = create("button");
|
||||
|
||||
classAdd(button, "button");
|
||||
classAdd(button, "sso-button");
|
||||
|
||||
button.innerText = "Login with Single Sign-On";
|
||||
|
||||
onclick(button, global.commentoAuth, {"provider": "sso", "id": id});
|
||||
|
||||
append(ssoButton, button);
|
||||
append(ssoButtonContainer, ssoButton);
|
||||
append(loginBox, ssoSubtitle);
|
||||
append(loginBox, ssoButtonContainer);
|
||||
|
||||
if (numOauthConfigured > 0 || configuredOauths["commento"]) {
|
||||
append(loginBox, hr1);
|
||||
}
|
||||
}
|
||||
|
||||
if (numOauthConfigured > 0) {
|
||||
append(loginBox, oauthSubtitle);
|
||||
append(oauthButtonsContainer, oauthButtons);
|
||||
@ -1301,7 +1329,7 @@
|
||||
append(loginLinkContainer, loginLink);
|
||||
|
||||
if (numOauthConfigured > 0 && configuredOauths["commento"]) {
|
||||
append(loginBox, hr);
|
||||
append(loginBox, hr2);
|
||||
}
|
||||
|
||||
if (configuredOauths["commento"]) {
|
||||
|
@ -19,4 +19,27 @@
|
||||
});
|
||||
};
|
||||
|
||||
global.ssoProviderChangeHandler = function() {
|
||||
var data = global.dashboard.$data;
|
||||
|
||||
if (data.domains[data.cd].ssoSecret === "") {
|
||||
var json = {
|
||||
"ownerToken": global.cookieGet("commentoOwnerToken"),
|
||||
"domain": data.domains[data.cd].domain,
|
||||
};
|
||||
|
||||
global.post(global.origin + "/api/domain/sso/new", json, function(resp) {
|
||||
if (!resp.success) {
|
||||
global.globalErrorShow(resp.message);
|
||||
return;
|
||||
}
|
||||
|
||||
data.domains[data.cd].ssoSecret = resp.ssoSecret;
|
||||
$("#sso-secret").val(data.domains[data.cd].ssoSecret);
|
||||
});
|
||||
} else {
|
||||
$("#sso-secret").val(data.domains[data.cd].ssoSecret);
|
||||
}
|
||||
};
|
||||
|
||||
} (window.commento, document));
|
||||
|
@ -37,5 +37,12 @@
|
||||
font-size: 13px;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.commento-sso-button {
|
||||
background: #000000;
|
||||
text-transform: uppercase;
|
||||
font-size: 13px;
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -367,6 +367,10 @@ body {
|
||||
font-size: 13px;
|
||||
line-height: 17px;
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -540,6 +544,11 @@ body {
|
||||
.input::placeholder {
|
||||
color: $gray-4;
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-family: "Source Code Pro", monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.theme {
|
||||
@ -598,7 +607,7 @@ body {
|
||||
|
||||
.indent {
|
||||
margin-top: 0px;
|
||||
padding-left: 32px;
|
||||
padding-left: 35px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
|
Loading…
Reference in New Issue
Block a user