diff --git a/account.go b/account.go index 3fbb1f2..d32f503 100644 --- a/account.go +++ b/account.go @@ -27,6 +27,7 @@ import ( "github.com/writeas/web-core/auth" "github.com/writeas/web-core/data" "github.com/writeas/web-core/log" + "github.com/writeas/writefreely/author" "github.com/writeas/writefreely/config" "github.com/writeas/writefreely/page" @@ -70,7 +71,7 @@ func canUserInvite(cfg *config.Config, isAdmin bool) bool { } func (up *UserPage) SetMessaging(u *User) { - //up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID) + // up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID) } const ( @@ -1042,18 +1043,52 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err flashes, _ := getSessionFlashes(app, w, r, nil) + enableOauthSlack := app.Config().SlackOauth.ClientID != "" + enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != "" + enableOauthGitLab := app.Config().GitlabOauth.ClientID != "" + + oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID) + if err != nil { + log.Error("Unable to get oauth accounts for settings: %s", err) + return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} + } + for _, oauthAccount := range oauthAccounts { + switch oauthAccount.Provider { + case "slack": + enableOauthSlack = false + case "write.as": + enableOauthWriteAs = false + case "gitlab": + enableOauthGitLab = false + } + } + + displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || len(oauthAccounts) > 0 + obj := struct { *UserPage - Email string - HasPass bool - IsLogOut bool - Silenced bool + Email string + HasPass bool + IsLogOut bool + Silenced bool + OauthSection bool + OauthAccounts []oauthAccountInfo + OauthSlack bool + OauthWriteAs bool + OauthGitLab bool + GitLabDisplayName string }{ - UserPage: NewUserPage(app, r, u, "Account Settings", flashes), - Email: fullUser.EmailClear(app.keys), - HasPass: passIsSet, - IsLogOut: r.FormValue("logout") == "1", - Silenced: fullUser.IsSilenced(), + UserPage: NewUserPage(app, r, u, "Account Settings", flashes), + Email: fullUser.EmailClear(app.keys), + HasPass: passIsSet, + IsLogOut: r.FormValue("logout") == "1", + Silenced: fullUser.IsSilenced(), + OauthSection: displayOauthSection, + OauthAccounts: oauthAccounts, + OauthSlack: enableOauthSlack, + OauthWriteAs: enableOauthWriteAs, + OauthGitLab: enableOauthGitLab, + GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName), } showUserPage(w, "settings", obj) @@ -1098,6 +1133,19 @@ func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) s return s } +func removeOauth(app *App, u *User, w http.ResponseWriter, r *http.Request) error { + provider := r.FormValue("provider") + clientID := r.FormValue("client_id") + remoteUserID := r.FormValue("remote_user_id") + + err := app.db.RemoveOauth(r.Context(), u.ID, provider, clientID, remoteUserID) + if err != nil { + return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()} + } + + return impart.HTTPError{Status: http.StatusFound, Message: "/me/settings"} +} + func prepareUserEmail(input string, emailKey []byte) zero.String { email := zero.NewString("", input != "") if len(input) > 0 { diff --git a/database.go b/database.go index 6beea1a..b3b89cb 100644 --- a/database.go +++ b/database.go @@ -130,8 +130,10 @@ 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, error) - GenerateOAuthState(context.Context, string, string) (string, error) + ValidateOAuthState(context.Context, string) (string, string, int64, error) + GenerateOAuthState(context.Context, string, string, int64) (string, error) + GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error) + RemoveOauth(ctx context.Context, userID int64, provider string, clientID string, remoteUserID string) error DatabaseInitialized() bool } @@ -2510,20 +2512,24 @@ func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) { return &t, nil } -func (db *datastore) GenerateOAuthState(ctx context.Context, provider, clientID string) (string, error) { +func (db *datastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUser int64) (string, error) { state := store.Generate62RandomString(24) - _, err := db.ExecContext(ctx, "INSERT INTO oauth_client_states (state, provider, client_id, used, created_at) VALUES (?, ?, ?, FALSE, "+db.now()+")", state, provider, clientID) + 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) 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, error) { +func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, error) { var provider string var clientID string + var attachUserID sql.NullInt64 err := wf_db.RunTransactionWithOptions(ctx, db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error { - err := tx.QueryRow("SELECT provider, client_id FROM oauth_client_states WHERE state = ? AND used = FALSE", state).Scan(&provider, &clientID) + 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) if err != nil { return err } @@ -2542,9 +2548,9 @@ func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (stri return nil }) if err != nil { - return "", "", nil + return "", "", 0, nil } - return provider, clientID, nil + return provider, clientID, attachUserID.Int64, nil } func (db *datastore) RecordRemoteUserID(ctx context.Context, localUserID int64, remoteUserID, provider, clientID, accessToken string) error { @@ -2573,6 +2579,33 @@ func (db *datastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provi return userID, nil } +type oauthAccountInfo struct { + Provider string + ClientID string + RemoteUserID string +} + +func (db *datastore) GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error) { + rows, err := db.QueryContext(ctx, "SELECT provider, client_id, remote_user_id FROM oauth_users WHERE user_id = ? ", userID) + if err != nil { + log.Error("Failed selecting from oauth_users: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user oauth accounts."} + } + defer rows.Close() + + var records []oauthAccountInfo + for rows.Next() { + info := oauthAccountInfo{} + err = rows.Scan(&info.Provider, &info.ClientID, &info.RemoteUserID) + if err != nil { + log.Error("Failed scanning GetAllUsers() row: %v", err) + break + } + records = append(records, info) + } + return records, nil +} + // DatabaseInitialized returns whether or not the current datastore has been // initialized with the correct schema. // Currently, it checks to see if the `users` table exists. @@ -2595,6 +2628,11 @@ func (db *datastore) DatabaseInitialized() bool { return true } +func (db *datastore) RemoveOauth(ctx context.Context, userID int64, provider string, clientID string, remoteUserID string) error { + _, err := db.ExecContext(ctx, `DELETE FROM oauth_users WHERE user_id = ? AND provider = ? AND client_id = ? AND remote_user_id = ?`, userID, provider, clientID, remoteUserID) + return err +} + func stringLogln(log *string, s string, v ...interface{}) { *log += fmt.Sprintf(s+"\n", v...) } diff --git a/database_test.go b/database_test.go index c4c586a..569d020 100644 --- a/database_test.go +++ b/database_test.go @@ -18,13 +18,13 @@ func TestOAuthDatastore(t *testing.T) { driverName: "", } - state, err := ds.GenerateOAuthState(ctx, "test", "development") + 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) diff --git a/go.mod b/go.mod index fe5b548..bb23137 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/guregu/null v3.4.0+incompatible github.com/hashicorp/go-multierror v1.0.0 github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 + github.com/jteeuwen/go-bindata v3.0.7+incompatible // indirect github.com/jtolds/gls v4.2.1+incompatible // indirect github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec github.com/lunixbochs/vtclean v1.0.0 // indirect @@ -51,6 +52,7 @@ require ( github.com/writeas/slug v1.2.0 github.com/writeas/web-core v1.2.0 github.com/writefreely/go-nodeinfo v1.2.0 + golang.org/dl v0.0.0-20200319204010-bf12898a6070 // indirect golang.org/x/crypto v0.0.0-20200109152110-61a87790db17 golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect diff --git a/go.sum b/go.sum index b0a423a..1de5654 100644 --- a/go.sum +++ b/go.sum @@ -73,6 +73,8 @@ github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uP github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM= github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw= +github.com/jteeuwen/go-bindata v3.0.7+incompatible h1:91Uy4d9SYVr1kyTJ15wJsog+esAZZl7JmEfTkwmhJts= +github.com/jteeuwen/go-bindata v3.0.7+incompatible/go.mod h1:JVvhzYOiGBnFSYRyV00iY8q7/0PThjIYav1p9h5dmKs= github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= @@ -165,6 +167,8 @@ github.com/writeas/web-core v1.2.0 h1:CYqvBd+byi1cK4mCr1NZ6CjILuMOFmiFecv+OACcmG github.com/writeas/web-core v1.2.0/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI= github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss= github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg= +golang.org/dl v0.0.0-20200319204010-bf12898a6070 h1:m3RoSUFYtel4F/gCw0tosY5Exe7hm2NbeNv/737FbSo= +golang.org/dl v0.0.0-20200319204010-bf12898a6070/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ= golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo= golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= diff --git a/migrations/migrations.go b/migrations/migrations.go index 41f036f..a5eea2d 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -62,6 +62,7 @@ var migrations = []Migration{ New("support oauth", oauth), // V3 -> V4 New("support slack oauth", oauthSlack), // V4 -> v5 New("support ActivityPub mentions", supportActivityPubMentions), // V5 -> V6 (v0.12.0) + New("support oauth attach", oauthAttach), // V6 -> V7 } // CurrentVer returns the current migration version the application is on diff --git a/migrations/v7.go b/migrations/v7.go new file mode 100644 index 0000000..3090cd9 --- /dev/null +++ b/migrations/v7.go @@ -0,0 +1,36 @@ +package migrations + +import ( + "context" + "database/sql" + + wf_db "github.com/writeas/writefreely/db" +) + +func oauthAttach(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( + "attach_user_id", + wf_db.ColumnTypeInteger, + wf_db.OptionalInt{Set: true, Value: 24}).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 + }) +} diff --git a/oauth.go b/oauth.go index 0aff0f3..9073f75 100644 --- a/oauth.go +++ b/oauth.go @@ -4,17 +4,19 @@ import ( "context" "encoding/json" "fmt" - "github.com/gorilla/mux" - "github.com/gorilla/sessions" - "github.com/writeas/impart" - "github.com/writeas/web-core/log" - "github.com/writeas/writefreely/config" "io" "io/ioutil" "net/http" "net/url" "strings" "time" + + "github.com/gorilla/mux" + "github.com/gorilla/sessions" + "github.com/writeas/impart" + "github.com/writeas/web-core/log" + + "github.com/writeas/writefreely/config" ) // TokenResponse contains data returned when a token is created either @@ -59,8 +61,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, error) - GenerateOAuthState(context.Context, string, string) (string, error) + ValidateOAuthState(context.Context, string) (string, string, int64, error) + GenerateOAuthState(context.Context, string, string, int64) (string, error) CreateUser(*config.Config, *User, string) error GetUserByID(int64) (*User, error) @@ -96,19 +98,32 @@ type oauthHandler struct { func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Request) error { ctx := r.Context() - state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID()) + + var attachUser int64 + if attach := r.URL.Query().Get("attach"); attach == "t" { + user, _ := getUserAndSession(app, r) + if user == nil { + return impart.HTTPError{http.StatusInternalServerError, "cannot attach auth to user: user not found in session"} + } + attachUser = user.ID + } + + state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID(), attachUser) if err != nil { + log.Error("viewOauthInit error: %s", err) return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"} } if h.callbackProxy != nil { if err := h.callbackProxy.register(ctx, state); err != nil { + log.Error("viewOauthInit error: %s", err) return impart.HTTPError{http.StatusInternalServerError, "could not register state server"} } } location, err := h.oauthClient.buildLoginURL(state) if err != nil { + log.Error("viewOauthInit error: %s", err) return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"} } return impart.HTTPError{http.StatusTemporaryRedirect, location} @@ -213,7 +228,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http code := r.FormValue("code") state := r.FormValue("state") - provider, clientID, err := h.DB.ValidateOAuthState(ctx, state) + provider, clientID, attachUserID, err := h.DB.ValidateOAuthState(ctx, state) if err != nil { log.Error("Unable to ValidateOAuthState: %s", err) return impart.HTTPError{http.StatusInternalServerError, err.Error()} @@ -239,6 +254,13 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http return impart.HTTPError{http.StatusInternalServerError, err.Error()} } + if localUserID != -1 && attachUserID > 0 { + if err = addSessionFlash(app, w, r, "This Slack account is already attached to another user.", nil); err != nil { + return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()} + } + return impart.HTTPError{http.StatusFound, "/me/settings"} + } + if localUserID != -1 { user, err := h.DB.GetUserByID(localUserID) if err != nil { @@ -251,6 +273,14 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http } return nil } + if attachUserID > 0 { + log.Info("attaching to user %d", attachUserID) + err = h.DB.RecordRemoteUserID(r.Context(), attachUserID, tokenInfo.UserID, provider, clientID, tokenResponse.AccessToken) + if err != nil { + return impart.HTTPError{http.StatusInternalServerError, err.Error()} + } + return impart.HTTPError{http.StatusFound, "/me/settings"} + } displayName := tokenInfo.DisplayName if len(displayName) == 0 { diff --git a/oauth_test.go b/oauth_test.go index 2e293e7..c23eadd 100644 --- a/oauth_test.go +++ b/oauth_test.go @@ -22,8 +22,8 @@ type MockOAuthDatastoreProvider struct { } type MockOAuthDatastore struct { - DoGenerateOAuthState func(context.Context, string, string) (string, error) - DoValidateOAuthState func(context.Context, string) (string, string, error) + DoGenerateOAuthState func(context.Context, string, string, int64) (string, error) + DoValidateOAuthState func(context.Context, string) (string, string, int64, 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, error) { +func (m *MockOAuthDatastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, error) { if m.DoValidateOAuthState != nil { return m.DoValidateOAuthState(ctx, state) } - return "", "", 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) (string, error) { +func (m *MockOAuthDatastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUserID int64) (string, error) { if m.DoGenerateOAuthState != nil { - return m.DoGenerateOAuthState(ctx, provider, clientID) + return m.DoGenerateOAuthState(ctx, provider, clientID, attachUserID) } 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) (string, error) { + DoGenerateOAuthState: func(ctx context.Context, provider, clientID string, attachUserID int64) (string, error) { return "", fmt.Errorf("pretend unable to write state error") }, } diff --git a/routes.go b/routes.go index 20f0eca..b34bd3d 100644 --- a/routes.go +++ b/routes.go @@ -115,6 +115,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST") apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST") apiMe.HandleFunc("/import", handler.User(handleImport)).Methods("POST") + apiMe.HandleFunc("/oauth/remove", handler.User(removeOauth)).Methods("POST") // Sign up validation write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST") diff --git a/static/img/mark/gitlab.png b/static/img/mark/gitlab.png new file mode 100644 index 0000000..214b0ad Binary files /dev/null and b/static/img/mark/gitlab.png differ diff --git a/static/img/mark/slack.png b/static/img/mark/slack.png new file mode 100644 index 0000000..33b4abc Binary files /dev/null and b/static/img/mark/slack.png differ diff --git a/static/img/mark/writeas.png b/static/img/mark/writeas.png new file mode 100644 index 0000000..777885b Binary files /dev/null and b/static/img/mark/writeas.png differ diff --git a/templates.go b/templates.go index c15c79b..5ee4bcf 100644 --- a/templates.go +++ b/templates.go @@ -37,6 +37,7 @@ var ( "localstr": localStr, "localhtml": localHTML, "tolower": strings.ToLower, + "title": strings.Title, } ) diff --git a/templates/user/settings.tmpl b/templates/user/settings.tmpl index df85770..8ade8ad 100644 --- a/templates/user/settings.tmpl +++ b/templates/user/settings.tmpl @@ -4,7 +4,13 @@
{{if .Silenced}} @@ -62,10 +68,64 @@ h3 { font-weight: normal; }
-
+
+ + {{ if .OauthSection }} +
+ + {{ if .OauthAccounts }} +
+

Linked Accounts

+

These are your linked external accounts.

+ {{ range $oauth_account := .OauthAccounts }} +
+ + + +
+ {{ $oauth_account.Provider | title }} + +
+
+ {{ end }} +
+ {{ end }} + {{ if or .OauthSlack .OauthWriteAs .OauthGitLab }} +
+

Link External Accounts

+

Connect additional accounts to enable logging in with those providers, instead of using your username and password.

+
+ {{ if .OauthWriteAs }} + + {{ end }} + {{ if .OauthSlack }} + + {{ end }} + {{ if .OauthGitLab }} + + {{ end }} +
+
+ {{ end }} + {{ end }}