mirror of
https://github.com/writefreely/writefreely
synced 2024-11-27 19:10:17 +00:00
Support OAuth registration with invite code
This adds any OAuth login buttons to the invite signup page, stores the invite code for the flow duration, and associates the new user with it once successfully registered. It enables invite-only instances with OAuth-based registration.
This commit is contained in:
parent
93c2773412
commit
ca4a576c31
13 changed files with 204 additions and 59 deletions
20
database.go
20
database.go
|
@ -132,8 +132,8 @@ type writestore interface {
|
|||
|
||||
GetIDForRemoteUser(context.Context, string, string, string) (int64, error)
|
||||
RecordRemoteUserID(context.Context, int64, string, string, string, string) error
|
||||
ValidateOAuthState(context.Context, string) (string, string, int64, error)
|
||||
GenerateOAuthState(context.Context, string, string, int64) (string, error)
|
||||
ValidateOAuthState(context.Context, string) (string, string, int64, string, error)
|
||||
GenerateOAuthState(context.Context, string, string, int64, string) (string, error)
|
||||
GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error)
|
||||
RemoveOauth(ctx context.Context, userID int64, provider string, clientID string, remoteUserID string) error
|
||||
|
||||
|
@ -2516,24 +2516,26 @@ func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) {
|
|||
return &t, nil
|
||||
}
|
||||
|
||||
func (db *datastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUser int64) (string, error) {
|
||||
func (db *datastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUser int64, inviteCode string) (string, error) {
|
||||
state := store.Generate62RandomString(24)
|
||||
attachUserVal := sql.NullInt64{Valid: attachUser > 0, Int64: attachUser}
|
||||
_, err := db.ExecContext(ctx, "INSERT INTO oauth_client_states (state, provider, client_id, used, created_at, attach_user_id) VALUES (?, ?, ?, FALSE, "+db.now()+", ?)", state, provider, clientID, attachUserVal)
|
||||
inviteCodeVal := sql.NullString{Valid: inviteCode != "", String: inviteCode}
|
||||
_, err := db.ExecContext(ctx, "INSERT INTO oauth_client_states (state, provider, client_id, used, created_at, attach_user_id, invite_code) VALUES (?, ?, ?, FALSE, "+db.now()+", ?, ?)", state, provider, clientID, attachUserVal, inviteCodeVal)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to record oauth client state: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, error) {
|
||||
func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, string, error) {
|
||||
var provider string
|
||||
var clientID string
|
||||
var attachUserID sql.NullInt64
|
||||
var inviteCode sql.NullString
|
||||
err := wf_db.RunTransactionWithOptions(ctx, db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
|
||||
err := tx.
|
||||
QueryRowContext(ctx, "SELECT provider, client_id, attach_user_id FROM oauth_client_states WHERE state = ? AND used = FALSE", state).
|
||||
Scan(&provider, &clientID, &attachUserID)
|
||||
QueryRowContext(ctx, "SELECT provider, client_id, attach_user_id, invite_code FROM oauth_client_states WHERE state = ? AND used = FALSE", state).
|
||||
Scan(&provider, &clientID, &attachUserID, &inviteCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -2552,9 +2554,9 @@ func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (stri
|
|||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", 0, nil
|
||||
return "", "", 0, "", nil
|
||||
}
|
||||
return provider, clientID, attachUserID.Int64, nil
|
||||
return provider, clientID, attachUserID.Int64, inviteCode.String, nil
|
||||
}
|
||||
|
||||
func (db *datastore) RecordRemoteUserID(ctx context.Context, localUserID int64, remoteUserID, provider, clientID, accessToken string) error {
|
||||
|
|
|
@ -18,13 +18,13 @@ func TestOAuthDatastore(t *testing.T) {
|
|||
driverName: "",
|
||||
}
|
||||
|
||||
state, err := ds.GenerateOAuthState(ctx, "test", "development", 0)
|
||||
state, err := ds.GenerateOAuthState(ctx, "test", "development", 0, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, state, 24)
|
||||
|
||||
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = false", state)
|
||||
|
||||
_, _, _, err = ds.ValidateOAuthState(ctx, state)
|
||||
_, _, _, _, err = ds.ValidateOAuthState(ctx, state)
|
||||
assert.NoError(t, err)
|
||||
|
||||
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = true", state)
|
||||
|
|
16
invites.go
16
invites.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -42,6 +42,18 @@ func (i Invite) Expired() bool {
|
|||
return i.Expires != nil && i.Expires.Before(time.Now())
|
||||
}
|
||||
|
||||
func (i Invite) Active(db *datastore) bool {
|
||||
if i.Expired() {
|
||||
return false
|
||||
}
|
||||
if i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
|
||||
if c := db.GetUsersInvitedCount(i.ID); c >= i.MaxUses.Int64 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (i Invite) ExpiresFriendly() string {
|
||||
return i.Expires.Format("January 2, 2006, 3:04 PM")
|
||||
}
|
||||
|
@ -161,9 +173,11 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
Error string
|
||||
Flashes []template.HTML
|
||||
Invite string
|
||||
OAuth *OAuthButtons
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
Invite: inviteCode,
|
||||
OAuth: NewOAuthButtons(app.cfg),
|
||||
}
|
||||
|
||||
if expired {
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
@import "post-temp";
|
||||
@import "effects";
|
||||
@import "admin";
|
||||
@import "login";
|
||||
@import "pages/error";
|
||||
@import "lib/elements";
|
||||
@import "lib/material";
|
||||
|
|
45
less/login.less
Normal file
45
less/login.less
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright © 2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
.row.signinbtns {
|
||||
justify-content: space-evenly;
|
||||
font-size: 1em;
|
||||
margin-top: 3em;
|
||||
margin-bottom: 2em;
|
||||
|
||||
.loginbtn {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
#writeas-login, #gitlab-login {
|
||||
box-sizing: border-box;
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.or {
|
||||
text-align: center;
|
||||
margin-bottom: 3.5em;
|
||||
|
||||
p {
|
||||
display: inline-block;
|
||||
background-color: white;
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-top: -1.6em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
hr.short {
|
||||
max-width: 30rem;
|
||||
}
|
||||
}
|
|
@ -62,7 +62,8 @@ var migrations = []Migration{
|
|||
New("support oauth", oauth), // V3 -> V4
|
||||
New("support slack oauth", oauthSlack), // V4 -> v5
|
||||
New("support ActivityPub mentions", supportActivityPubMentions), // V5 -> V6
|
||||
New("support oauth attach", oauthAttach), // V6 -> V7 (v0.12.0)
|
||||
New("support oauth attach", oauthAttach), // V6 -> V7
|
||||
New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0)
|
||||
}
|
||||
|
||||
// CurrentVer returns the current migration version the application is on
|
||||
|
|
45
migrations/v8.go
Normal file
45
migrations/v8.go
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright © 2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writeas/writefreely/db"
|
||||
)
|
||||
|
||||
func oauthInvites(db *datastore) error {
|
||||
dialect := wf_db.DialectMySQL
|
||||
if db.driverName == driverSQLite {
|
||||
dialect = wf_db.DialectSQLite
|
||||
}
|
||||
return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
|
||||
builders := []wf_db.SQLBuilder{
|
||||
dialect.
|
||||
AlterTable("oauth_client_states").
|
||||
AddColumn(dialect.Column("invite_code", wf_db.ColumnTypeChar, wf_db.OptionalInt{
|
||||
Set: true,
|
||||
Value: 6,
|
||||
}).SetNullable(true)),
|
||||
}
|
||||
for _, builder := range builders {
|
||||
query, err := builder.ToSQL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, query); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
49
oauth.go
49
oauth.go
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
|
@ -15,10 +25,27 @@ import (
|
|||
"github.com/gorilla/sessions"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/log"
|
||||
|
||||
"github.com/writeas/writefreely/config"
|
||||
)
|
||||
|
||||
// OAuthButtons holds display information for different OAuth providers we support.
|
||||
type OAuthButtons struct {
|
||||
SlackEnabled bool
|
||||
WriteAsEnabled bool
|
||||
GitLabEnabled bool
|
||||
GitLabDisplayName string
|
||||
}
|
||||
|
||||
// NewOAuthButtons creates a new OAuthButtons struct based on our app configuration.
|
||||
func NewOAuthButtons(cfg *config.Config) *OAuthButtons {
|
||||
return &OAuthButtons{
|
||||
SlackEnabled: cfg.SlackOauth.ClientID != "",
|
||||
WriteAsEnabled: cfg.WriteAsOauth.ClientID != "",
|
||||
GitLabEnabled: cfg.GitlabOauth.ClientID != "",
|
||||
GitLabDisplayName: config.OrDefaultString(cfg.GitlabOauth.DisplayName, gitlabDisplayName),
|
||||
}
|
||||
}
|
||||
|
||||
// TokenResponse contains data returned when a token is created either
|
||||
// through a code exchange or using a refresh token.
|
||||
type TokenResponse struct {
|
||||
|
@ -61,8 +88,8 @@ type OAuthDatastoreProvider interface {
|
|||
type OAuthDatastore interface {
|
||||
GetIDForRemoteUser(context.Context, string, string, string) (int64, error)
|
||||
RecordRemoteUserID(context.Context, int64, string, string, string, string) error
|
||||
ValidateOAuthState(context.Context, string) (string, string, int64, error)
|
||||
GenerateOAuthState(context.Context, string, string, int64) (string, error)
|
||||
ValidateOAuthState(context.Context, string) (string, string, int64, string, error)
|
||||
GenerateOAuthState(context.Context, string, string, int64, string) (string, error)
|
||||
|
||||
CreateUser(*config.Config, *User, string) error
|
||||
GetUserByID(int64) (*User, error)
|
||||
|
@ -108,7 +135,7 @@ func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Req
|
|||
attachUser = user.ID
|
||||
}
|
||||
|
||||
state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID(), attachUser)
|
||||
state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID(), attachUser, r.FormValue("invite_code"))
|
||||
if err != nil {
|
||||
log.Error("viewOauthInit error: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
|
||||
|
@ -228,7 +255,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
code := r.FormValue("code")
|
||||
state := r.FormValue("state")
|
||||
|
||||
provider, clientID, attachUserID, err := h.DB.ValidateOAuthState(ctx, state)
|
||||
provider, clientID, attachUserID, inviteCode, err := h.DB.ValidateOAuthState(ctx, state)
|
||||
if err != nil {
|
||||
log.Error("Unable to ValidateOAuthState: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
|
@ -285,7 +312,16 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
|
||||
// New user registration below.
|
||||
// First, verify that user is allowed to register
|
||||
if !app.cfg.App.OpenRegistration {
|
||||
if inviteCode != "" {
|
||||
// Verify invite code is valid
|
||||
i, err := app.db.GetUserInvite(inviteCode)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
if !i.Active(app.db) {
|
||||
return impart.HTTPError{http.StatusNotFound, "Invite link has expired."}
|
||||
}
|
||||
} else if !app.cfg.App.OpenRegistration {
|
||||
addSessionFlash(app, w, r, ErrUserNotFound.Error(), nil)
|
||||
return impart.HTTPError{http.StatusFound, "/login"}
|
||||
}
|
||||
|
@ -303,6 +339,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
TokenRemoteUser: tokenInfo.UserID,
|
||||
Provider: provider,
|
||||
ClientID: clientID,
|
||||
InviteCode: inviteCode,
|
||||
}
|
||||
tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed)
|
||||
|
||||
|
|
|
@ -38,6 +38,7 @@ type viewOauthSignupVars struct {
|
|||
Provider string
|
||||
ClientID string
|
||||
TokenHash string
|
||||
InviteCode string
|
||||
|
||||
LoginUsername string
|
||||
Alias string // TODO: rename this to match the data it represents: the collection title
|
||||
|
@ -57,6 +58,7 @@ const (
|
|||
oauthParamAlias = "alias"
|
||||
oauthParamEmail = "email"
|
||||
oauthParamPassword = "password"
|
||||
oauthParamInviteCode = "invite_code"
|
||||
)
|
||||
|
||||
type oauthSignupPageParams struct {
|
||||
|
@ -68,6 +70,7 @@ type oauthSignupPageParams struct {
|
|||
ClientID string
|
||||
Provider string
|
||||
TokenHash string
|
||||
InviteCode string
|
||||
}
|
||||
|
||||
func (p oauthSignupPageParams) HashTokenParams(key string) string {
|
||||
|
@ -92,6 +95,7 @@ func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.R
|
|||
TokenRemoteUser: r.FormValue(oauthParamTokenRemoteUserID),
|
||||
ClientID: r.FormValue(oauthParamClientID),
|
||||
Provider: r.FormValue(oauthParamProvider),
|
||||
InviteCode: r.FormValue(oauthParamInviteCode),
|
||||
}
|
||||
if tp.HashTokenParams(h.Config.Server.HashSeed) != r.FormValue(oauthParamHash) {
|
||||
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Request has been tampered with."}
|
||||
|
@ -128,6 +132,14 @@ func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.R
|
|||
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||
}
|
||||
|
||||
// Log invite if needed
|
||||
if tp.InviteCode != "" {
|
||||
err = app.db.CreateInvitedUser(tp.InviteCode, newUser.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = h.DB.RecordRemoteUserID(r.Context(), newUser.ID, r.FormValue(oauthParamTokenRemoteUserID), r.FormValue(oauthParamProvider), r.FormValue(oauthParamClientID), r.FormValue(oauthParamAccessToken))
|
||||
if err != nil {
|
||||
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||
|
@ -195,6 +207,7 @@ func (h oauthHandler) showOauthSignupPage(app *App, w http.ResponseWriter, r *ht
|
|||
Provider: tp.Provider,
|
||||
ClientID: tp.ClientID,
|
||||
TokenHash: tp.TokenHash,
|
||||
InviteCode: tp.InviteCode,
|
||||
|
||||
LoginUsername: username,
|
||||
Alias: collTitle,
|
||||
|
|
|
@ -22,8 +22,8 @@ type MockOAuthDatastoreProvider struct {
|
|||
}
|
||||
|
||||
type MockOAuthDatastore struct {
|
||||
DoGenerateOAuthState func(context.Context, string, string, int64) (string, error)
|
||||
DoValidateOAuthState func(context.Context, string) (string, string, int64, error)
|
||||
DoGenerateOAuthState func(context.Context, string, string, int64, string) (string, error)
|
||||
DoValidateOAuthState func(context.Context, string) (string, string, int64, string, error)
|
||||
DoGetIDForRemoteUser func(context.Context, string, string, string) (int64, error)
|
||||
DoCreateUser func(*config.Config, *User, string) error
|
||||
DoRecordRemoteUserID func(context.Context, int64, string, string, string, string) error
|
||||
|
@ -86,11 +86,11 @@ func (m *MockOAuthDatastoreProvider) Config() *config.Config {
|
|||
return cfg
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, error) {
|
||||
func (m *MockOAuthDatastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, string, error) {
|
||||
if m.DoValidateOAuthState != nil {
|
||||
return m.DoValidateOAuthState(ctx, state)
|
||||
}
|
||||
return "", "", 0, nil
|
||||
return "", "", 0, "", nil
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provider, clientID string) (int64, error) {
|
||||
|
@ -125,9 +125,9 @@ func (m *MockOAuthDatastore) GetUserByID(userID int64) (*User, error) {
|
|||
return user, nil
|
||||
}
|
||||
|
||||
func (m *MockOAuthDatastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUserID int64) (string, error) {
|
||||
func (m *MockOAuthDatastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUserID int64, inviteCode string) (string, error) {
|
||||
if m.DoGenerateOAuthState != nil {
|
||||
return m.DoGenerateOAuthState(ctx, provider, clientID, attachUserID)
|
||||
return m.DoGenerateOAuthState(ctx, provider, clientID, attachUserID, inviteCode)
|
||||
}
|
||||
return store.Generate62RandomString(14), nil
|
||||
}
|
||||
|
@ -173,7 +173,7 @@ func TestViewOauthInit(t *testing.T) {
|
|||
app := &MockOAuthDatastoreProvider{
|
||||
DoDB: func() OAuthDatastore {
|
||||
return &MockOAuthDatastore{
|
||||
DoGenerateOAuthState: func(ctx context.Context, provider, clientID string, attachUserID int64) (string, error) {
|
||||
DoGenerateOAuthState: func(ctx context.Context, provider, clientID string, attachUserID int64, inviteCode string) (string, error) {
|
||||
return "", fmt.Errorf("pretend unable to write state error")
|
||||
},
|
||||
}
|
||||
|
|
|
@ -3,39 +3,6 @@
|
|||
<meta itemprop="description" content="Log in to {{.SiteName}}.">
|
||||
<style>
|
||||
input{margin-bottom:0.5em;}
|
||||
.or {
|
||||
text-align: center;
|
||||
margin-bottom: 3.5em;
|
||||
}
|
||||
.or p {
|
||||
display: inline-block;
|
||||
background-color: white;
|
||||
padding: 0 1em;
|
||||
}
|
||||
.or hr {
|
||||
margin-top: -1.6em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
hr.short {
|
||||
max-width: 30rem;
|
||||
}
|
||||
.row.signinbtns {
|
||||
justify-content: space-evenly;
|
||||
font-size: 1em;
|
||||
margin-top: 3em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.loginbtn {
|
||||
height: 40px;
|
||||
}
|
||||
#writeas-login {
|
||||
box-sizing: border-box;
|
||||
font-size: 17px;
|
||||
}
|
||||
#gitlab-login {
|
||||
box-sizing: border-box;
|
||||
font-size: 17px;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
|
|
|
@ -74,6 +74,7 @@ form dd {
|
|||
<input type="hidden" name="provider" value="{{ .Provider }}" />
|
||||
<input type="hidden" name="client_id" value="{{ .ClientID }}" />
|
||||
<input type="hidden" name="signature" value="{{ .TokenHash }}" />
|
||||
{{if .InviteCode}}<input type="hidden" name="invite_code" value="{{ .InviteCode }}" />{{end}}
|
||||
|
||||
<dl class="billing">
|
||||
<label>
|
||||
|
|
|
@ -70,6 +70,25 @@ form dd {
|
|||
</ul>{{end}}
|
||||
|
||||
<div id="billing">
|
||||
{{ if or .OAuth.SlackEnabled .OAuth.WriteAsEnabled .OAuth.GitLabEnabled }}
|
||||
<div class="row content-container signinbtns">
|
||||
{{ if .OAuth.SlackEnabled }}
|
||||
<a class="loginbtn" href="/oauth/slack{{if .Invite}}?invite_code={{.Invite}}{{end}}"><img alt="Sign in with Slack" height="40" width="172" src="/img/sign_in_with_slack.png" srcset="/img/sign_in_with_slack.png 1x, /img/sign_in_with_slack@2x.png 2x" /></a>
|
||||
{{ end }}
|
||||
{{ if .OAuth.WriteAsEnabled }}
|
||||
<a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as{{if .Invite}}?invite_code={{.Invite}}{{end}}">Sign in with <strong>Write.as</strong></a>
|
||||
{{ end }}
|
||||
{{ if .OAuth.GitLabEnabled }}
|
||||
<a class="btn cta loginbtn" id="gitlab-login" href="/oauth/gitlab{{if .Invite}}?invite_code={{.Invite}}{{end}}">Sign in with <strong>{{.OAuth.GitLabDisplayName}}</strong></a>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="or">
|
||||
<p>or</p>
|
||||
<hr class="short" />
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<form action="/auth/signup" method="POST" id="signup-form" onsubmit="return signup()">
|
||||
<input type="hidden" name="invite_code" value="{{.Invite}}" />
|
||||
<dl class="billing">
|
||||
|
|
Loading…
Reference in a new issue