From ecd30f5983d0cc62a45325b49546b4650439a16a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 29 Nov 2023 11:51:42 -0600 Subject: [PATCH 1/8] feat: add endpoints to list all authed external apps Also add support for unlinking on the coder side to allow reflow. --- coderd/apidoc/docs.go | 75 ++++++++++++++++++++ coderd/apidoc/swagger.json | 69 ++++++++++++++++++ coderd/coderd.go | 17 +++-- coderd/database/db2sdk/db2sdk.go | 18 +++++ coderd/database/dbauthz/dbauthz.go | 12 ++-- coderd/database/dbmem/dbmem.go | 23 ++++++ coderd/database/dbmetrics/dbmetrics.go | 7 ++ coderd/database/dbmock/dbmock.go | 14 ++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 14 ++++ coderd/database/queries/externalauth.sql | 3 + coderd/externalauth.go | 89 ++++++++++++++++++++++++ coderd/externalauth_test.go | 55 +++++++++++++++ codersdk/externalauth.go | 60 ++++++++++++++++ docs/api/default.md | 27 +++++++ docs/api/schemas.md | 22 ++++++ docs/api/users.md | 35 ++++++++++ docs/manifest.json | 4 ++ site/src/api/typesGenerated.ts | 26 +++++++ 19 files changed, 562 insertions(+), 9 deletions(-) create mode 100644 docs/api/default.md diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e2b2894a65449..c9a826ba60fe2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -781,6 +781,33 @@ const docTemplate = `{ } } } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "summary": "Delete external auth user link by ID", + "operationId": "delete-external-auth-user-link-by-id", + "parameters": [ + { + "type": "string", + "format": "string", + "description": "Git Provider ID", + "name": "externalauth", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } } }, "/external-auth/{externalauth}/device": { @@ -3437,6 +3464,31 @@ const docTemplate = `{ } } }, + "/users/external-auths": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get user external auths", + "operationId": "get-user-external-auths", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ExternalAuthLink" + } + } + } + } + }, "/users/first": { "get": { "security": [ @@ -8852,6 +8904,29 @@ const docTemplate = `{ } } }, + "codersdk.ExternalAuthLink": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires": { + "type": "string", + "format": "date-time" + }, + "has_refresh_token": { + "type": "boolean" + }, + "provider_id": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, "codersdk.ExternalAuthUser": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d62fbfb69751b..92d8593c0129d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -667,6 +667,31 @@ } } } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "summary": "Delete external auth user link by ID", + "operationId": "delete-external-auth-user-link-by-id", + "parameters": [ + { + "type": "string", + "format": "string", + "description": "Git Provider ID", + "name": "externalauth", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } } }, "/external-auth/{externalauth}/device": { @@ -3023,6 +3048,27 @@ } } }, + "/users/external-auths": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Get user external auths", + "operationId": "get-user-external-auths", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ExternalAuthLink" + } + } + } + } + }, "/users/first": { "get": { "security": [ @@ -7944,6 +7990,29 @@ } } }, + "codersdk.ExternalAuthLink": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires": { + "type": "string", + "format": "date-time" + }, + "has_refresh_token": { + "type": "boolean" + }, + "provider_id": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, "codersdk.ExternalAuthUser": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 08784b6ff4e43..5b10a2e09e969 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -660,14 +660,21 @@ func New(options *Options) *API { r.Get("/{fileID}", api.fileByID) r.Post("/", api.postFile) }) - r.Route("/external-auth/{externalauth}", func(r chi.Router) { + r.Route("/external-auth", func(r chi.Router) { r.Use( apiKeyMiddleware, - httpmw.ExtractExternalAuthParam(options.ExternalAuthConfigs), ) - r.Get("/", api.externalAuthByID) - r.Post("/device", api.postExternalAuthDeviceByID) - r.Get("/device", api.externalAuthDeviceByID) + // Get without a specific external auth ID will return all external auths. + r.Get("/", api.userExternalAuths) + r.Route("/{externalauth}", func(r chi.Router) { + r.Use( + httpmw.ExtractExternalAuthParam(options.ExternalAuthConfigs), + ) + r.Delete("/", api.deleteExternalAuthByID) + r.Get("/", api.externalAuthByID) + r.Post("/device", api.postExternalAuthDeviceByID) + r.Get("/device", api.externalAuthDeviceByID) + }) }) r.Route("/organizations", func(r chi.Router) { r.Use( diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 6add6d0146796..f1104f7b1213a 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -16,6 +16,24 @@ import ( "github.com/coder/coder/v2/provisionersdk/proto" ) +func ExternalAuths(auths []database.ExternalAuthLink) []codersdk.ExternalAuthLink { + out := make([]codersdk.ExternalAuthLink, 0, len(auths)) + for _, auth := range auths { + out = append(out, ExternalAuth(auth)) + } + return out +} + +func ExternalAuth(auth database.ExternalAuthLink) codersdk.ExternalAuthLink { + return codersdk.ExternalAuthLink{ + ProviderID: auth.ProviderID, + CreatedAt: auth.CreatedAt, + UpdatedAt: auth.UpdatedAt, + HasRefreshToken: auth.OAuthRefreshToken != "", + Expires: auth.OAuthExpiry, + } +} + func WorkspaceBuildParameters(params []database.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter { out := make([]codersdk.WorkspaceBuildParameter, len(params)) for i, p := range params { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 1ba76a6c7431c..bc16fac9f97d8 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -754,6 +754,13 @@ func (q *querier) DeleteCoordinator(ctx context.Context, id uuid.UUID) error { return q.db.DeleteCoordinator(ctx, id) } +func (q *querier) DeleteExternalAuthLink(ctx context.Context, arg database.DeleteExternalAuthLinkParams) error { + return deleteQ(q.log, q.auth, func(ctx context.Context, arg database.DeleteExternalAuthLinkParams) (database.ExternalAuthLink, error) { + //nolint:gosimple + return q.db.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{UserID: arg.UserID, ProviderID: arg.ProviderID}) + }, q.db.DeleteExternalAuthLink)(ctx, arg) +} + func (q *querier) DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error { return deleteQ(q.log, q.auth, q.db.GetGitSSHKey, q.db.DeleteGitSSHKey)(ctx, userID) } @@ -996,10 +1003,7 @@ func (q *querier) GetExternalAuthLink(ctx context.Context, arg database.GetExter } func (q *querier) GetExternalAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]database.ExternalAuthLink, error) { - if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { - return nil, err - } - return q.db.GetExternalAuthLinksByUserID(ctx, userID) + return fetchWithPostFilter(q.auth, q.db.GetExternalAuthLinksByUserID)(ctx, userID) } func (q *querier) GetFileByHashAndCreator(ctx context.Context, arg database.GetFileByHashAndCreatorParams) (database.File, error) { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 4cae64776d4d9..abb14f983e22a 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1027,6 +1027,29 @@ func (*FakeQuerier) DeleteCoordinator(context.Context, uuid.UUID) error { return ErrUnimplemented } +func (q *FakeQuerier) DeleteExternalAuthLink(ctx context.Context, arg database.DeleteExternalAuthLinkParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, key := range q.externalAuthLinks { + if key.UserID != arg.UserID { + continue + } + if key.ProviderID != arg.ProviderID { + continue + } + q.externalAuthLinks[index] = q.externalAuthLinks[len(q.externalAuthLinks)-1] + q.externalAuthLinks = q.externalAuthLinks[:len(q.externalAuthLinks)-1] + return nil + } + return sql.ErrNoRows +} + func (q *FakeQuerier) DeleteGitSSHKey(_ context.Context, userID uuid.UUID) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index e7ecf99596e4b..225b7cebbe4de 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -176,6 +176,13 @@ func (m metricsStore) DeleteCoordinator(ctx context.Context, id uuid.UUID) error return m.s.DeleteCoordinator(ctx, id) } +func (m metricsStore) DeleteExternalAuthLink(ctx context.Context, arg database.DeleteExternalAuthLinkParams) error { + start := time.Now() + r0 := m.s.DeleteExternalAuthLink(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteExternalAuthLink").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error { start := time.Now() err := m.s.DeleteGitSSHKey(ctx, userID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e2abf1f91eaff..2ba4b0328d353 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -238,6 +238,20 @@ func (mr *MockStoreMockRecorder) DeleteCoordinator(arg0, arg1 interface{}) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCoordinator", reflect.TypeOf((*MockStore)(nil).DeleteCoordinator), arg0, arg1) } +// DeleteExternalAuthLink mocks base method. +func (m *MockStore) DeleteExternalAuthLink(arg0 context.Context, arg1 database.DeleteExternalAuthLinkParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteExternalAuthLink", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteExternalAuthLink indicates an expected call of DeleteExternalAuthLink. +func (mr *MockStoreMockRecorder) DeleteExternalAuthLink(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExternalAuthLink", reflect.TypeOf((*MockStore)(nil).DeleteExternalAuthLink), arg0, arg1) +} + // DeleteGitSSHKey mocks base method. func (m *MockStore) DeleteGitSSHKey(arg0 context.Context, arg1 uuid.UUID) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a0115fb22bd1c..7e7d2bfacf738 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -51,6 +51,7 @@ type sqlcQuerier interface { DeleteAllTailnetTunnels(ctx context.Context, arg DeleteAllTailnetTunnelsParams) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error DeleteCoordinator(ctx context.Context, id uuid.UUID) error + DeleteExternalAuthLink(ctx context.Context, arg DeleteExternalAuthLinkParams) error DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error DeleteGroupByID(ctx context.Context, id uuid.UUID) error DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteGroupMemberFromGroupParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a99a1cefc9884..fb7b15cf26866 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -788,6 +788,20 @@ func (q *sqlQuerier) RevokeDBCryptKey(ctx context.Context, activeKeyDigest strin return err } +const deleteExternalAuthLink = `-- name: DeleteExternalAuthLink :exec +DELETE FROM external_auth_links WHERE provider_id = $1 AND user_id = $2 +` + +type DeleteExternalAuthLinkParams struct { + ProviderID string `db:"provider_id" json:"provider_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` +} + +func (q *sqlQuerier) DeleteExternalAuthLink(ctx context.Context, arg DeleteExternalAuthLinkParams) error { + _, err := q.db.ExecContext(ctx, deleteExternalAuthLink, arg.ProviderID, arg.UserID) + return err +} + const getExternalAuthLink = `-- name: GetExternalAuthLink :one SELECT provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, oauth_extra FROM external_auth_links WHERE provider_id = $1 AND user_id = $2 ` diff --git a/coderd/database/queries/externalauth.sql b/coderd/database/queries/externalauth.sql index dfc195b9ea886..8470c44ea9125 100644 --- a/coderd/database/queries/externalauth.sql +++ b/coderd/database/queries/externalauth.sql @@ -1,6 +1,9 @@ -- name: GetExternalAuthLink :one SELECT * FROM external_auth_links WHERE provider_id = $1 AND user_id = $2; +-- name: DeleteExternalAuthLink :exec +DELETE FROM external_auth_links WHERE provider_id = $1 AND user_id = $2; + -- name: GetExternalAuthLinksByUserID :many SELECT * FROM external_auth_links WHERE user_id = $1; diff --git a/coderd/externalauth.go b/coderd/externalauth.go index b1b7acc8bc449..752b3edce3919 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -10,6 +10,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/httpapi" @@ -77,6 +78,38 @@ func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, w, http.StatusOK, res) } +// @Summary Delete external auth user link by ID +// @ID delete-external-auth-user-link-by-id +// @Security CoderSessionToken +// @Produce json +// @Param externalauth path string true "Git Provider ID" format(string) +// @Success 200 +// @Router /external-auth/{externalauth} [delete] +// deleteExternalAuthByID only deletes the link on the Coder side, does not revoke the token on the provider side. +func (api *API) deleteExternalAuthByID(w http.ResponseWriter, r *http.Request) { + config := httpmw.ExternalAuthParam(r) + apiKey := httpmw.APIKey(r) + ctx := r.Context() + + err := api.Database.DeleteExternalAuthLink(ctx, database.DeleteExternalAuthLinkParams{ + ProviderID: config.ID, + UserID: apiKey.UserID, + }) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(w) + return + } + httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to delete external auth link.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, w, http.StatusOK, "OK") +} + // @Summary Post external auth device by ID // @ID post-external-auth-device-by-id // @Security CoderSessionToken @@ -275,3 +308,59 @@ func (api *API) externalAuthCallback(externalAuthConfig *externalauth.Config) ht http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } } + +// @Summary Get user external auths +// @ID get-user-external-auths +// @Security CoderSessionToken +// @Produce json +// @Tags Users +// @Success 200 {object} codersdk.ExternalAuthLink +// @Router /users/external-auths [get] +func (api *API) userExternalAuths(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + key := httpmw.APIKey(r) + + links, err := api.Database.GetExternalAuthLinksByUserID(ctx, key.UserID) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching user's external auths.", + Detail: err.Error(), + }) + return + } + + // Note: It would be really nice if we could cfg.Validate() the links and + // return their authenticated status. To do this, we would also have to + // refresh expired tokens too. + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ListUserExternalAuthResponse{ + Providers: ExternalAuthConfigs(api.ExternalAuthConfigs), + Links: db2sdk.ExternalAuths(links), + }) +} + +func ExternalAuthConfigs(auths []*externalauth.Config) []codersdk.ExternalAuthLinkProvider { + out := make([]codersdk.ExternalAuthLinkProvider, 0, len(auths)) + for _, auth := range auths { + if auth == nil { + continue + } + out = append(out, ExternalAuthConfig(auth)) + } + return out +} + +func ExternalAuthConfig(cfg *externalauth.Config) codersdk.ExternalAuthLinkProvider { + return codersdk.ExternalAuthLinkProvider{ + ID: cfg.ID, + Type: cfg.Type, + Device: cfg.DeviceAuth != nil, + DisplayName: cfg.DisplayName, + DisplayIcon: cfg.DisplayIcon, + AllowRefresh: !cfg.NoRefresh, + AllowValidate: cfg.ValidateURL != "", + } +} diff --git a/coderd/externalauth_test.go b/coderd/externalauth_test.go index 41ef1ac11dce5..34c1fe7bcdc1e 100644 --- a/coderd/externalauth_test.go +++ b/coderd/externalauth_test.go @@ -145,6 +145,61 @@ func TestExternalAuthByID(t *testing.T) { }) } +// TestExternalAuthManagement is for testing the apis interacting with +// external auths from the user perspective. We assume the external auth +// will always work, so we can test the managing apis like unlinking and +// listing. +func TestExternalAuthManagement(t *testing.T) { + t.Parallel() + t.Run("ListProviders", func(t *testing.T) { + t.Parallel() + const githubID = "fake-github" + const gitlabID = "fake-gitlab" + + github := oidctest.NewFakeIDP(t, oidctest.WithServing()) + gitlab := oidctest.NewFakeIDP(t, oidctest.WithServing()) + + owner := coderdtest.New(t, &coderdtest.Options{ + ExternalAuthConfigs: []*externalauth.Config{ + github.ExternalAuthConfig(t, githubID, nil, func(cfg *externalauth.Config) { + cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String() + }), + gitlab.ExternalAuthConfig(t, gitlabID, nil, func(cfg *externalauth.Config) { + cfg.Type = codersdk.EnhancedExternalAuthProviderGitLab.String() + }), + }, + }) + ownerUser := coderdtest.CreateFirstUser(t, owner) + // Just a regular user + client, _ := coderdtest.CreateAnotherUser(t, owner, ownerUser.OrganizationID) + ctx := testutil.Context(t, testutil.WaitLong) + + // List auths without any links. + list, err := client.ListExternalAuths(ctx) + require.NoError(t, err) + require.Len(t, list.Providers, 2) + require.Len(t, list.Links, 0) + + // Log into github + github.ExternalLogin(t, client) + + list, err = client.ListExternalAuths(ctx) + require.NoError(t, err) + require.Len(t, list.Providers, 2) + require.Len(t, list.Links, 1) + require.Equal(t, list.Links[0].ProviderID, githubID) + + // Unlink + err = client.UnlinkExternalAuthByID(ctx, githubID) + require.NoError(t, err) + + list, err = client.ListExternalAuths(ctx) + require.NoError(t, err) + require.Len(t, list.Providers, 2) + require.Len(t, list.Links, 0) + }) +} + func TestExternalAuthDevice(t *testing.T) { t.Parallel() t.Run("NotSupported", func(t *testing.T) { diff --git a/codersdk/externalauth.go b/codersdk/externalauth.go index 8d858670eed3d..157f1307684be 100644 --- a/codersdk/externalauth.go +++ b/codersdk/externalauth.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "time" ) // EnhancedExternalAuthProvider is a constant that represents enhanced @@ -57,6 +58,36 @@ type ExternalAuth struct { AppInstallURL string `json:"app_install_url"` } +type ListUserExternalAuthResponse struct { + Providers []ExternalAuthLinkProvider `json:"providers"` + // Links are all the authenticated links for the user. + // If a link has a provider ID that does not exist, then that provider + // is no longer configured, rendering it unusable. + Links []ExternalAuthLink `json:"links"` +} + +// ExternalAuthLink is a link between a user and an external auth provider. +// It excludes information that requires a token to access, so can be statically +// built from the database and configs. +type ExternalAuthLink struct { + ProviderID string `json:"provider_id"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` + HasRefreshToken bool `json:"has_refresh_token"` + Expires time.Time `json:"expires" format:"date-time"` +} + +// ExternalAuthLinkProvider are the static details of a provider. +type ExternalAuthLinkProvider struct { + ID string `json:"id"` + Type string `json:"type"` + Device bool `json:"device"` + DisplayName string `json:"display_name"` + DisplayIcon string `json:"display_icon"` + AllowRefresh bool `json:"allow_refresh"` + AllowValidate bool `json:"allow_validate"` +} + type ExternalAuthAppInstallation struct { ID int `json:"id"` Account ExternalAuthUser `json:"account"` @@ -123,3 +154,32 @@ func (c *Client) ExternalAuthByID(ctx context.Context, provider string) (Externa var extAuth ExternalAuth return extAuth, json.NewDecoder(res.Body).Decode(&extAuth) } + +// UnlinkExternalAuthByID deletes the external auth for the given provider by ID +// for the user. This does not revoke the token from the IDP. +func (c *Client) UnlinkExternalAuthByID(ctx context.Context, provider string) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/external-auth/%s", provider), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ReadBodyAsError(res) + } + return nil +} + +// ListExternalAuths returns the available external auth providers and the user's +// authenticated links if they exist. +func (c *Client) ListExternalAuths(ctx context.Context) (ListUserExternalAuthResponse, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/external-auth", nil) + if err != nil { + return ListUserExternalAuthResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ListUserExternalAuthResponse{}, ReadBodyAsError(res) + } + var extAuth ListUserExternalAuthResponse + return extAuth, json.NewDecoder(res.Body).Decode(&extAuth) +} diff --git a/docs/api/default.md b/docs/api/default.md new file mode 100644 index 0000000000000..000a623af0547 --- /dev/null +++ b/docs/api/default.md @@ -0,0 +1,27 @@ +# Default + +## Delete external auth user link by ID + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/external-auth/{externalauth} \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /external-auth/{externalauth}` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | -------------- | -------- | --------------- | +| `externalauth` | path | string(string) | true | Git Provider ID | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 03547650a5843..b709a33a57fe6 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3001,6 +3001,28 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `user_code` | string | false | | | | `verification_uri` | string | false | | | +## codersdk.ExternalAuthLink + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "expires": "2019-08-24T14:15:22Z", + "has_refresh_token": true, + "provider_id": "string", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------- | ------- | -------- | ------------ | ----------- | +| `created_at` | string | false | | | +| `expires` | string | false | | | +| `has_refresh_token` | boolean | false | | | +| `provider_id` | string | false | | | +| `updated_at` | string | false | | | + ## codersdk.ExternalAuthUser ```json diff --git a/docs/api/users.md b/docs/api/users.md index 1ea652b3ab2ef..f9a435ba4e2e0 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -165,6 +165,41 @@ curl -X GET http://coder-server:8080/api/v2/users/authmethods \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get user external auths + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/users/external-auths \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /users/external-auths` + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "expires": "2019-08-24T14:15:22Z", + "has_refresh_token": true, + "provider_id": "string", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ExternalAuthLink](schemas.md#codersdkexternalauthlink) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Check initial user created ### Code samples diff --git a/docs/manifest.json b/docs/manifest.json index eb4276ca99ebe..553e19425ac8f 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -523,6 +523,10 @@ "title": "Debug", "path": "./api/debug.md" }, + { + "title": "Default", + "path": "./api/default.md" + }, { "title": "Enterprise", "path": "./api/enterprise.md" diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5545336e22e6e..b594ae7dfdf5e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -495,6 +495,26 @@ export interface ExternalAuthDeviceExchange { readonly device_code: string; } +// From codersdk/externalauth.go +export interface ExternalAuthLink { + readonly provider_id: string; + readonly created_at: string; + readonly updated_at: string; + readonly has_refresh_token: boolean; + readonly expires: string; +} + +// From codersdk/externalauth.go +export interface ExternalAuthLinkProvider { + readonly id: string; + readonly type: string; + readonly device: boolean; + readonly display_name: string; + readonly display_icon: string; + readonly allow_refresh: boolean; + readonly allow_validate: boolean; +} + // From codersdk/externalauth.go export interface ExternalAuthUser { readonly login: string; @@ -588,6 +608,12 @@ export interface LinkConfig { readonly icon: string; } +// From codersdk/externalauth.go +export interface ListUserExternalAuthResponse { + readonly providers: ExternalAuthLinkProvider[]; + readonly links: ExternalAuthLink[]; +} + // From codersdk/deployment.go export interface LoggingConfig { readonly log_filter: string[]; From 919e490c259325986f6755b49979d5847b858e37 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 30 Nov 2023 14:53:10 -0600 Subject: [PATCH 2/8] Update swagger --- coderd/apidoc/docs.go | 54 +++++++++++++++--------------- coderd/apidoc/swagger.json | 44 ++++++++++++------------ coderd/coderd.go | 2 +- coderd/database/dbmem/dbmem.go | 2 +- coderd/externalauth.go | 22 +++++++----- codersdk/externalauth.go | 3 +- docs/api/default.md | 27 --------------- docs/api/git.md | 61 ++++++++++++++++++++++++++++++++++ docs/api/users.md | 35 ------------------- docs/manifest.json | 4 --- 10 files changed, 128 insertions(+), 126 deletions(-) delete mode 100644 docs/api/default.md diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c9a826ba60fe2..4943392452e75 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -748,6 +748,31 @@ const docTemplate = `{ } } }, + "/external-auth": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Git" + ], + "summary": "Get user external auths", + "operationId": "get-user-external-auths", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ExternalAuthLink" + } + } + } + } + }, "/external-auth/{externalauth}": { "get": { "security": [ @@ -788,8 +813,8 @@ const docTemplate = `{ "CoderSessionToken": [] } ], - "produces": [ - "application/json" + "tags": [ + "Git" ], "summary": "Delete external auth user link by ID", "operationId": "delete-external-auth-user-link-by-id", @@ -3464,31 +3489,6 @@ const docTemplate = `{ } } }, - "/users/external-auths": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Users" - ], - "summary": "Get user external auths", - "operationId": "get-user-external-auths", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.ExternalAuthLink" - } - } - } - } - }, "/users/first": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 92d8593c0129d..2503b3cfe1a0c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -638,6 +638,27 @@ } } }, + "/external-auth": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Git"], + "summary": "Get user external auths", + "operationId": "get-user-external-auths", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ExternalAuthLink" + } + } + } + } + }, "/external-auth/{externalauth}": { "get": { "security": [ @@ -674,7 +695,7 @@ "CoderSessionToken": [] } ], - "produces": ["application/json"], + "tags": ["Git"], "summary": "Delete external auth user link by ID", "operationId": "delete-external-auth-user-link-by-id", "parameters": [ @@ -3048,27 +3069,6 @@ } } }, - "/users/external-auths": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": ["application/json"], - "tags": ["Users"], - "summary": "Get user external auths", - "operationId": "get-user-external-auths", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.ExternalAuthLink" - } - } - } - } - }, "/users/first": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 5b10a2e09e969..c5dbc9a89d061 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -665,7 +665,7 @@ func New(options *Options) *API { apiKeyMiddleware, ) // Get without a specific external auth ID will return all external auths. - r.Get("/", api.userExternalAuths) + r.Get("/", api.listUserExternalAuths) r.Route("/{externalauth}", func(r chi.Router) { r.Use( httpmw.ExtractExternalAuthParam(options.ExternalAuthConfigs), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index abb14f983e22a..9fdd028225819 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1027,7 +1027,7 @@ func (*FakeQuerier) DeleteCoordinator(context.Context, uuid.UUID) error { return ErrUnimplemented } -func (q *FakeQuerier) DeleteExternalAuthLink(ctx context.Context, arg database.DeleteExternalAuthLinkParams) error { +func (q *FakeQuerier) DeleteExternalAuthLink(_ context.Context, arg database.DeleteExternalAuthLinkParams) error { err := validateDatabaseType(arg) if err != nil { return err diff --git a/coderd/externalauth.go b/coderd/externalauth.go index 752b3edce3919..32d182a249c1e 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -21,8 +21,8 @@ import ( // @Summary Get external auth by ID // @ID get-external-auth-by-id // @Security CoderSessionToken -// @Produce json // @Tags Git +// @Produce json // @Param externalauth path string true "Git Provider ID" format(string) // @Success 200 {object} codersdk.ExternalAuth // @Router /external-auth/{externalauth} [get] @@ -78,14 +78,15 @@ func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, w, http.StatusOK, res) } +// deleteExternalAuthByID only deletes the link on the Coder side, does not revoke the token on the provider side. +// // @Summary Delete external auth user link by ID // @ID delete-external-auth-user-link-by-id // @Security CoderSessionToken -// @Produce json -// @Param externalauth path string true "Git Provider ID" format(string) +// @Tags Git // @Success 200 +// @Param externalauth path string true "Git Provider ID" format(string) // @Router /external-auth/{externalauth} [delete] -// deleteExternalAuthByID only deletes the link on the Coder side, does not revoke the token on the provider side. func (api *API) deleteExternalAuthByID(w http.ResponseWriter, r *http.Request) { config := httpmw.ExternalAuthParam(r) apiKey := httpmw.APIKey(r) @@ -309,14 +310,17 @@ func (api *API) externalAuthCallback(externalAuthConfig *externalauth.Config) ht } } +// listUserExternalAuths lists all external auths available to a user and +// their auth links if they exist. +// // @Summary Get user external auths // @ID get-user-external-auths // @Security CoderSessionToken // @Produce json -// @Tags Users +// @Tags Git // @Success 200 {object} codersdk.ExternalAuthLink -// @Router /users/external-auths [get] -func (api *API) userExternalAuths(rw http.ResponseWriter, r *http.Request) { +// @Router /external-auth [get] +func (api *API) listUserExternalAuths(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() key := httpmw.APIKey(r) @@ -335,7 +339,9 @@ func (api *API) userExternalAuths(rw http.ResponseWriter, r *http.Request) { // Note: It would be really nice if we could cfg.Validate() the links and // return their authenticated status. To do this, we would also have to - // refresh expired tokens too. + // refresh expired tokens too. For now, I do not want to cause the excess + // traffic on this request, so the user will have to do this with a separate + // call. httpapi.Write(ctx, rw, http.StatusOK, codersdk.ListUserExternalAuthResponse{ Providers: ExternalAuthConfigs(api.ExternalAuthConfigs), Links: db2sdk.ExternalAuths(links), diff --git a/codersdk/externalauth.go b/codersdk/externalauth.go index 157f1307684be..ad2988edb7d74 100644 --- a/codersdk/externalauth.go +++ b/codersdk/externalauth.go @@ -62,7 +62,8 @@ type ListUserExternalAuthResponse struct { Providers []ExternalAuthLinkProvider `json:"providers"` // Links are all the authenticated links for the user. // If a link has a provider ID that does not exist, then that provider - // is no longer configured, rendering it unusable. + // is no longer configured, rendering it unusable. It is still valuable + // to include these links so that the user can unlink them. Links []ExternalAuthLink `json:"links"` } diff --git a/docs/api/default.md b/docs/api/default.md deleted file mode 100644 index 000a623af0547..0000000000000 --- a/docs/api/default.md +++ /dev/null @@ -1,27 +0,0 @@ -# Default - -## Delete external auth user link by ID - -### Code samples - -```shell -# Example request using curl -curl -X DELETE http://coder-server:8080/api/v2/external-auth/{externalauth} \ - -H 'Coder-Session-Token: API_KEY' -``` - -`DELETE /external-auth/{externalauth}` - -### Parameters - -| Name | In | Type | Required | Description | -| -------------- | ---- | -------------- | -------- | --------------- | -| `externalauth` | path | string(string) | true | Git Provider ID | - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/git.md b/docs/api/git.md index 9f2014705da7f..07678b0b8f2c5 100644 --- a/docs/api/git.md +++ b/docs/api/git.md @@ -1,5 +1,40 @@ # Git +## Get user external auths + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/external-auth \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /external-auth` + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "expires": "2019-08-24T14:15:22Z", + "has_refresh_token": true, + "provider_id": "string", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ExternalAuthLink](schemas.md#codersdkexternalauthlink) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get external auth by ID ### Code samples @@ -59,6 +94,32 @@ curl -X GET http://coder-server:8080/api/v2/external-auth/{externalauth} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Delete external auth user link by ID + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/external-auth/{externalauth} \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /external-auth/{externalauth}` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | -------------- | -------- | --------------- | +| `externalauth` | path | string(string) | true | Git Provider ID | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get external auth device by ID. ### Code samples diff --git a/docs/api/users.md b/docs/api/users.md index f9a435ba4e2e0..1ea652b3ab2ef 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -165,41 +165,6 @@ curl -X GET http://coder-server:8080/api/v2/users/authmethods \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get user external auths - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/users/external-auths \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /users/external-auths` - -### Example responses - -> 200 Response - -```json -{ - "created_at": "2019-08-24T14:15:22Z", - "expires": "2019-08-24T14:15:22Z", - "has_refresh_token": true, - "provider_id": "string", - "updated_at": "2019-08-24T14:15:22Z" -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ExternalAuthLink](schemas.md#codersdkexternalauthlink) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - ## Check initial user created ### Code samples diff --git a/docs/manifest.json b/docs/manifest.json index 553e19425ac8f..eb4276ca99ebe 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -523,10 +523,6 @@ "title": "Debug", "path": "./api/debug.md" }, - { - "title": "Default", - "path": "./api/default.md" - }, { "title": "Enterprise", "path": "./api/enterprise.md" From aa92509efe3fb8e6ebf9c37b394618ecab863157 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 29 Nov 2023 11:54:49 -0600 Subject: [PATCH 3/8] feat: add user/settings page for managing external auth --- site/src/AppRouter.tsx | 18 +- site/src/api/api.ts | 13 ++ site/src/api/queries/externalauth.ts | 30 ++++ .../Dialogs/DeleteDialog/DeleteDialog.tsx | 18 +- .../src/components/SettingsLayout/Sidebar.tsx | 4 + .../UserExternalAuthSettingsPage.tsx | 80 +++++++++ ...erExternalAuthSettingsPageView.stories.tsx | 36 ++++ .../UserExternalAuthSettingsPageView.tsx | 156 ++++++++++++++++++ 8 files changed, 347 insertions(+), 8 deletions(-) create mode 100644 site/src/api/queries/externalauth.ts create mode 100644 site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx create mode 100644 site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx create mode 100644 site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index ff0adab82398e..c53f2fa7ba725 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -131,6 +131,10 @@ const ObservabilitySettingsPage = lazy( const ExternalAuthPage = lazy( () => import("./pages/ExternalAuthPage/ExternalAuthPage"), ); +const UserExternalAuthSettingsPage = lazy( + () => + import("./pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage"), +); const TemplateVersionPage = lazy( () => import("./pages/TemplateVersionPage/TemplateVersionPage"), ); @@ -259,6 +263,10 @@ export const AppRouter: FC = () => { } /> + } + /> @@ -314,6 +322,10 @@ export const AppRouter: FC = () => { } /> } /> } /> + } + /> } /> } /> @@ -345,17 +357,13 @@ export const AppRouter: FC = () => { } /> - {/* Pages that don't have the dashboard layout */} + {/* Terminal and CLI auth pages don't have the dashboard layout */} } /> } /> } /> - } - /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 9513ce023e449..d78b81f7626f1 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -939,6 +939,19 @@ export const exchangeExternalAuthDevice = async ( return resp.data; }; +export const getUserExternalAuthProviders = async ( +): Promise => { + const resp = await axios.get(`/api/v2/external-auth`); + return resp.data; +}; + +export const unlinkExternalAuthProvider = async ( + provider: string, +): Promise => { + const resp = await axios.delete(`/api/v2/external-auth/${provider}`); + return resp.data; +}; + export const getAuditLogs = async ( options: TypesGen.AuditLogsRequest, ): Promise => { diff --git a/site/src/api/queries/externalauth.ts b/site/src/api/queries/externalauth.ts new file mode 100644 index 0000000000000..b4f66819c3b87 --- /dev/null +++ b/site/src/api/queries/externalauth.ts @@ -0,0 +1,30 @@ +import * as API from "api/api"; +import { QueryClient } from "react-query"; + +const getUserExternalAuthsKey = () => ["list", "external-auth"]; + +// listUserExternalAuths returns all configured external auths for a given user. +export const listUserExternalAuths = () => { + return { + queryKey: getUserExternalAuthsKey(), + queryFn: () => API.getUserExternalAuthProviders(), + }; +}; + +export const validateExternalAuth = (_: QueryClient) => { + return { + mutationFn: API.getExternalAuthProvider, + onSuccess: async () => { + // No invalidation needed. + }, + }; +}; + +export const unlinkExternalAuths = (queryClient: QueryClient) => { + return { + mutationFn: API.unlinkExternalAuthProvider, + onSuccess: async () => { + await queryClient.invalidateQueries(["external-auth"]); + }, + }; +}; diff --git a/site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx b/site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx index 9593c0f969af9..149488b557c10 100644 --- a/site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx +++ b/site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx @@ -18,6 +18,10 @@ export interface DeleteDialogProps { name: string; info?: string; confirmLoading?: boolean; + verb?: string; + title?: string; + label?: string; + confirmText?: string; } export const DeleteDialog: FC> = ({ @@ -28,6 +32,11 @@ export const DeleteDialog: FC> = ({ info, name, confirmLoading, + // All optional to change the verbiage. For example, "unlinking" vs "deleting" + verb, + title, + label, + confirmText, }) => { const hookId = useId(); const theme = useTheme(); @@ -52,14 +61,17 @@ export const DeleteDialog: FC> = ({ type="delete" hideCancel={false} open={isOpen} - title={`Delete ${entity}`} + title={title ?? `Delete ${entity}`} onConfirm={onConfirm} onClose={onCancel} confirmLoading={confirmLoading} disabled={!deletionConfirmed} + confirmText={confirmText} description={ <> -

Deleting this {entity} is irreversible!

+

+ {verb ?? "Deleting"} this {entity} is irreversible! +

{Boolean(info) && (

{info}

@@ -84,7 +96,7 @@ export const DeleteDialog: FC> = ({ onChange={(event) => setUserConfirmationText(event.target.value)} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} - label={`Name of the ${entity} to delete`} + label={label ?? `Name of the ${entity} to delete`} color={inputColor} error={displayErrorMessage} helperText={ diff --git a/site/src/components/SettingsLayout/Sidebar.tsx b/site/src/components/SettingsLayout/Sidebar.tsx index 6657aab80ab58..a6fc879171c49 100644 --- a/site/src/components/SettingsLayout/Sidebar.tsx +++ b/site/src/components/SettingsLayout/Sidebar.tsx @@ -11,6 +11,7 @@ import { SidebarHeader, SidebarNavItem, } from "components/Sidebar/Sidebar"; +import { GitIcon } from "components/Icons/GitIcon"; export const Sidebar: React.FC<{ user: User }> = ({ user }) => { const { entitlements } = useDashboard(); @@ -40,6 +41,9 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => { SSH Keys + + External Authentication + Tokens diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx new file mode 100644 index 0000000000000..e5c76e038e672 --- /dev/null +++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx @@ -0,0 +1,80 @@ +import { FC, useState } from "react"; +import { UserExternalAuthSettingsPageView } from "./UserExternalAuthSettingsPageView"; +import { + listUserExternalAuths, + unlinkExternalAuths, + validateExternalAuth, +} from "api/queries/externalauth"; +import { Section } from "components/SettingsLayout/Section"; +import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { getErrorMessage } from "api/errors"; + +const UserExternalAuthSettingsPage: FC = () => { + const queryClient = useQueryClient(); + + const userExternalAuthsQuery = useQuery(listUserExternalAuths()); + + const [appToUnlink, setAppToUnlink] = useState(); + const unlinkAppMutation = useMutation(unlinkExternalAuths(queryClient)); + + const validateAppMutation = useMutation(validateExternalAuth(queryClient)); + + return ( +
+ { + setAppToUnlink(providerID); + }} + onValidateExternalAuth={async (providerID: string) => { + try { + await validateAppMutation.mutateAsync(providerID, { + onSuccess: (data) => { + if (data.authenticated) { + displaySuccess("Application link is valid."); + } else { + displayError( + "Application link is not valid. Please unlink the application and reauthenticate.", + ); + } + }, + }); + } catch (e) { + displayError( + getErrorMessage(e, "Error validating application link."), + ); + } + }} + /> + setAppToUnlink(undefined)} + onConfirm={async () => { + try { + await unlinkAppMutation.mutateAsync(appToUnlink!); + setAppToUnlink(undefined); + displaySuccess("Successfully unlinked the oauth2 application."); + } catch (e) { + displayError(getErrorMessage(e, "Error unlinking application.")); + } + }} + /> +
+ ); +}; + +export default UserExternalAuthSettingsPage; diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx new file mode 100644 index 0000000000000..31084048549c0 --- /dev/null +++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx @@ -0,0 +1,36 @@ +// import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView"; +// import type { Meta, StoryObj } from "@storybook/react"; + +// const meta: Meta = { +// title: "pages/DeploySettingsPage/ExternalAuthSettingsPageView", +// component: ExternalAuthSettingsPageView, +// args: { +// config: { +// external_auth: [ +// { +// id: "0000-1111", +// type: "GitHub", +// client_id: "client_id", +// regex: "regex", +// auth_url: "", +// token_url: "", +// validate_url: "", +// app_install_url: "https://github.com/apps/coder/installations/new", +// app_installations_url: "", +// no_refresh: false, +// scopes: [], +// extra_token_keys: [], +// device_flow: true, +// device_code_url: "", +// display_icon: "", +// display_name: "GitHub", +// }, +// ], +// }, +// }, +// }; + +// export default meta; +// type Story = StoryObj; + +// export const Page: Story = {}; diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx new file mode 100644 index 0000000000000..e5775e661fd56 --- /dev/null +++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx @@ -0,0 +1,156 @@ +import { css } from "@emotion/react"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import type { + ExternalAuthConfig, + ExternalAuthLinkProvider, + UserExternalAuthResponse, +} from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { EnterpriseBadge } from "components/Badges/Badges"; +import { Header } from "components/DeploySettingsLayout/Header"; +import { docs } from "utils/docs"; +import { Avatar } from "components/Avatar/Avatar"; +import { AvatarData } from "components/AvatarData/AvatarData"; +import { ExternalAuth } from "pages/CreateWorkspacePage/ExternalAuth"; +import { Stack } from "components/Stack/Stack"; +import Divider from "@mui/material/Divider"; +import { + MoreMenu, + MoreMenuContent, + MoreMenuItem, + MoreMenuTrigger, + ThreeDotsButton, +} from "components/MoreMenu/MoreMenu"; + +export type UserExternalAuthSettingsPageViewProps = { + isLoading: boolean; + getAuthsError?: unknown; + auths?: UserExternalAuthResponse; + onUnlinkExternalAuth: (provider: string) => void; + onValidateExternalAuth: (provider: string) => void; +}; + +export const UserExternalAuthSettingsPageView = ({ + isLoading, + getAuthsError, + auths, + onUnlinkExternalAuth, + onValidateExternalAuth, +}: UserExternalAuthSettingsPageViewProps): JSX.Element => { + if (getAuthsError) { + // Nothing to show if there is an error + return ; + } + + if (!auths) { + // TODO: Do loading? + return <>; + } + + return ( + <> + + + + + Application + Link + + + + + {((auths.providers === null || auths.providers?.length === 0) && ( + + +
+ No providers have been configured! +
+
+
+ )) || + auths.providers?.map((app: ExternalAuthLinkProvider) => { + const name = app.id || app.type; + const link = auths.links.find((l) => l.provider_id === name); + const authURL = "/external-auth/" + app.id; + return ( + + + + ) + } + /> + + + {}} + > + + + {link && ( + + + + + + { + onValidateExternalAuth(app.id); + }} + > + Test Validate… + + + { + onUnlinkExternalAuth(app.id); + }} + > + Unlink… + + + + )} + + + ); + })} +
+
+
+ + ); +}; From a84ed83ddd84a25e8b986588130708a446cfb649 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 1 Dec 2023 11:23:48 -0600 Subject: [PATCH 4/8] fix rename fixes on ui --- site/src/api/api.ts | 2 +- .../UserExternalAuthSettingsPageView.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index d78b81f7626f1..f681a34add6f4 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -940,7 +940,7 @@ export const exchangeExternalAuthDevice = async ( }; export const getUserExternalAuthProviders = async ( -): Promise => { +): Promise => { const resp = await axios.get(`/api/v2/external-auth`); return resp.data; }; diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx index e5775e661fd56..b83bfeba4ce7c 100644 --- a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx +++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx @@ -7,8 +7,8 @@ import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import type { ExternalAuthConfig, + ListUserExternalAuthResponse, ExternalAuthLinkProvider, - UserExternalAuthResponse, } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; @@ -31,7 +31,7 @@ import { export type UserExternalAuthSettingsPageViewProps = { isLoading: boolean; getAuthsError?: unknown; - auths?: UserExternalAuthResponse; + auths?: ListUserExternalAuthResponse; onUnlinkExternalAuth: (provider: string) => void; onValidateExternalAuth: (provider: string) => void; }; From add4d6f00b78d4ed8acc9f2f17fc7f74e6ec0372 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 4 Dec 2023 16:27:58 -0600 Subject: [PATCH 5/8] update FE to add external settings page --- coderd/apidoc/docs.go | 6 + coderd/apidoc/swagger.json | 6 + coderd/database/db2sdk/db2sdk.go | 13 +- coderd/externalauth.go | 32 ++- codersdk/externalauth.go | 2 + docs/api/git.md | 4 +- docs/api/schemas.md | 6 +- site/src/api/queries/externalauth.ts | 13 + site/src/api/typesGenerated.ts | 2 + .../CreateWorkspacePage/ExternalAuth.tsx | 25 +- .../UserExternalAuthSettingsPage.tsx | 27 +- .../UserExternalAuthSettingsPageView.tsx | 246 ++++++++++++------ 12 files changed, 281 insertions(+), 101 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4943392452e75..13a63762b814b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8907,6 +8907,9 @@ const docTemplate = `{ "codersdk.ExternalAuthLink": { "type": "object", "properties": { + "authenticated": { + "type": "boolean" + }, "created_at": { "type": "string", "format": "date-time" @@ -8924,6 +8927,9 @@ const docTemplate = `{ "updated_at": { "type": "string", "format": "date-time" + }, + "validate_error": { + "type": "string" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2503b3cfe1a0c..a22a9ed0bc7a9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7993,6 +7993,9 @@ "codersdk.ExternalAuthLink": { "type": "object", "properties": { + "authenticated": { + "type": "boolean" + }, "created_at": { "type": "string", "format": "date-time" @@ -8010,6 +8013,9 @@ "updated_at": { "type": "string", "format": "date-time" + }, + "validate_error": { + "type": "string" } } }, diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index f1104f7b1213a..e17d5e32d8476 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -16,21 +16,28 @@ import ( "github.com/coder/coder/v2/provisionersdk/proto" ) -func ExternalAuths(auths []database.ExternalAuthLink) []codersdk.ExternalAuthLink { +type ExternalAuthMeta struct { + Authenticated bool + ValidateError string +} + +func ExternalAuths(auths []database.ExternalAuthLink, meta map[string]ExternalAuthMeta) []codersdk.ExternalAuthLink { out := make([]codersdk.ExternalAuthLink, 0, len(auths)) for _, auth := range auths { - out = append(out, ExternalAuth(auth)) + out = append(out, ExternalAuth(auth, meta[auth.ProviderID])) } return out } -func ExternalAuth(auth database.ExternalAuthLink) codersdk.ExternalAuthLink { +func ExternalAuth(auth database.ExternalAuthLink, meta ExternalAuthMeta) codersdk.ExternalAuthLink { return codersdk.ExternalAuthLink{ ProviderID: auth.ProviderID, CreatedAt: auth.CreatedAt, UpdatedAt: auth.UpdatedAt, HasRefreshToken: auth.OAuthRefreshToken != "", Expires: auth.OAuthExpiry, + Authenticated: meta.Authenticated, + ValidateError: meta.ValidateError, } } diff --git a/coderd/externalauth.go b/coderd/externalauth.go index 32d182a249c1e..b9d7e665b1637 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -337,6 +337,36 @@ func (api *API) listUserExternalAuths(rw http.ResponseWriter, r *http.Request) { return } + // This process of authenticating each external link increases the + // response time. However, it is necessary to more correctly debug + // authentication issues. + // We can do this in parallel if we want to speed it up. + configs := make(map[string]*externalauth.Config) + for _, cfg := range api.ExternalAuthConfigs { + configs[cfg.ID] = cfg + } + // Check if the links are authenticated. + linkMeta := make(map[string]db2sdk.ExternalAuthMeta) + for i, link := range links { + if link.OAuthAccessToken != "" { + cfg, ok := configs[link.ProviderID] + if ok { + newLink, valid, err := cfg.RefreshToken(ctx, api.Database, link) + meta := db2sdk.ExternalAuthMeta{ + Authenticated: valid, + } + if err != nil { + meta.ValidateError = err.Error() + } + // Update the link if it was potentially refreshed. + if err == nil && valid { + links[i] = newLink + } + break + } + } + } + // Note: It would be really nice if we could cfg.Validate() the links and // return their authenticated status. To do this, we would also have to // refresh expired tokens too. For now, I do not want to cause the excess @@ -344,7 +374,7 @@ func (api *API) listUserExternalAuths(rw http.ResponseWriter, r *http.Request) { // call. httpapi.Write(ctx, rw, http.StatusOK, codersdk.ListUserExternalAuthResponse{ Providers: ExternalAuthConfigs(api.ExternalAuthConfigs), - Links: db2sdk.ExternalAuths(links), + Links: db2sdk.ExternalAuths(links, linkMeta), }) } diff --git a/codersdk/externalauth.go b/codersdk/externalauth.go index ad2988edb7d74..d0b48c1a058fe 100644 --- a/codersdk/externalauth.go +++ b/codersdk/externalauth.go @@ -76,6 +76,8 @@ type ExternalAuthLink struct { UpdatedAt time.Time `json:"updated_at" format:"date-time"` HasRefreshToken bool `json:"has_refresh_token"` Expires time.Time `json:"expires" format:"date-time"` + Authenticated bool `json:"authenticated"` + ValidateError string `json:"validate_error"` } // ExternalAuthLinkProvider are the static details of a provider. diff --git a/docs/api/git.md b/docs/api/git.md index 07678b0b8f2c5..71a0d2921f5fa 100644 --- a/docs/api/git.md +++ b/docs/api/git.md @@ -19,11 +19,13 @@ curl -X GET http://coder-server:8080/api/v2/external-auth \ ```json { + "authenticated": true, "created_at": "2019-08-24T14:15:22Z", "expires": "2019-08-24T14:15:22Z", "has_refresh_token": true, "provider_id": "string", - "updated_at": "2019-08-24T14:15:22Z" + "updated_at": "2019-08-24T14:15:22Z", + "validate_error": "string" } ``` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index b709a33a57fe6..8b223a6451d10 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3005,11 +3005,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { + "authenticated": true, "created_at": "2019-08-24T14:15:22Z", "expires": "2019-08-24T14:15:22Z", "has_refresh_token": true, "provider_id": "string", - "updated_at": "2019-08-24T14:15:22Z" + "updated_at": "2019-08-24T14:15:22Z", + "validate_error": "string" } ``` @@ -3017,11 +3019,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | | ------------------- | ------- | -------- | ------------ | ----------- | +| `authenticated` | boolean | false | | | | `created_at` | string | false | | | | `expires` | string | false | | | | `has_refresh_token` | boolean | false | | | | `provider_id` | string | false | | | | `updated_at` | string | false | | | +| `validate_error` | string | false | | | ## codersdk.ExternalAuthUser diff --git a/site/src/api/queries/externalauth.ts b/site/src/api/queries/externalauth.ts index b4f66819c3b87..ab9671dbfd023 100644 --- a/site/src/api/queries/externalauth.ts +++ b/site/src/api/queries/externalauth.ts @@ -11,6 +11,19 @@ export const listUserExternalAuths = () => { }; }; +const getUserExternalAuthKey = (providerID: string) => [ + providerID, + "get", + "external-auth", +]; + +export const userExternalAuth = (providerID: string) => { + return { + queryKey: getUserExternalAuthKey(providerID), + queryFn: () => API.getExternalAuthProvider(providerID), + }; +}; + export const validateExternalAuth = (_: QueryClient) => { return { mutationFn: API.getExternalAuthProvider, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b594ae7dfdf5e..627da1aba2c11 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -502,6 +502,8 @@ export interface ExternalAuthLink { readonly updated_at: string; readonly has_refresh_token: boolean; readonly expires: string; + readonly authenticated: boolean; + readonly validate_error: string; } // From codersdk/externalauth.go diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx index c1e61ac74832b..691b6681afddb 100644 --- a/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx +++ b/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx @@ -15,6 +15,7 @@ export interface ExternalAuthProps { externalAuthPollingState: ExternalAuthPollingState; startPollingExternalAuth: () => void; error?: string; + message?: string; } export const ExternalAuth: FC = (props) => { @@ -26,8 +27,14 @@ export const ExternalAuth: FC = (props) => { externalAuthPollingState, startPollingExternalAuth, error, + message, } = props; + const messageContent = + message ?? + (authenticated + ? `Authenticated with ${displayName}` + : `Login with ${displayName}`); return ( = (props) => { variant="contained" size="large" startIcon={ - {`${displayName} + displayIcon && ( + {`${displayName} + ) } disabled={authenticated} css={{ height: 52 }} @@ -61,9 +70,7 @@ export const ExternalAuth: FC = (props) => { startPollingExternalAuth(); }} > - {authenticated - ? `Authenticated with ${displayName}` - : `Login with ${displayName}`} + {messageContent} {externalAuthPollingState === "abandoned" && ( diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx index e5c76e038e672..4fd9e5c6741cc 100644 --- a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx +++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx @@ -13,20 +13,37 @@ import { getErrorMessage } from "api/errors"; const UserExternalAuthSettingsPage: FC = () => { const queryClient = useQueryClient(); + // This is used to tell the child components something was unlinked and things + // need to be refetched + const [unlinked, setUnlinked] = useState(0); - const userExternalAuthsQuery = useQuery(listUserExternalAuths()); + const { + data: externalAuths, + error, + isLoading, + refetch, + } = useQuery(listUserExternalAuths()); const [appToUnlink, setAppToUnlink] = useState(); - const unlinkAppMutation = useMutation(unlinkExternalAuths(queryClient)); + const mutateParams = unlinkExternalAuths(queryClient); + const unlinkAppMutation = useMutation({ + ...mutateParams, + onSuccess: async () => { + await mutateParams.onSuccess(); + await refetch(); + setUnlinked(unlinked + 1); + }, + }); const validateAppMutation = useMutation(validateExternalAuth(queryClient)); return (
{ setAppToUnlink(providerID); }} diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx index b83bfeba4ce7c..855b09edd68fe 100644 --- a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx +++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx @@ -1,4 +1,3 @@ -import { css } from "@emotion/react"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; @@ -6,19 +5,14 @@ import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import type { - ExternalAuthConfig, ListUserExternalAuthResponse, ExternalAuthLinkProvider, + ExternalAuthLink, } from "api/typesGenerated"; -import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { EnterpriseBadge } from "components/Badges/Badges"; -import { Header } from "components/DeploySettingsLayout/Header"; -import { docs } from "utils/docs"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/AvatarData/AvatarData"; import { ExternalAuth } from "pages/CreateWorkspacePage/ExternalAuth"; -import { Stack } from "components/Stack/Stack"; import Divider from "@mui/material/Divider"; import { MoreMenu, @@ -27,10 +21,16 @@ import { MoreMenuTrigger, ThreeDotsButton, } from "components/MoreMenu/MoreMenu"; +import { ExternalAuthPollingState } from "pages/CreateWorkspacePage/CreateWorkspacePage"; +import { useState, useCallback, useEffect } from "react"; +import { useQuery } from "react-query"; +import { userExternalAuth } from "api/queries/externalauth"; +import { FullScreenLoader } from "components/Loader/FullScreenLoader"; export type UserExternalAuthSettingsPageViewProps = { isLoading: boolean; getAuthsError?: unknown; + unlinked: number; auths?: ListUserExternalAuthResponse; onUnlinkExternalAuth: (provider: string) => void; onValidateExternalAuth: (provider: string) => void; @@ -40,6 +40,7 @@ export const UserExternalAuthSettingsPageView = ({ isLoading, getAuthsError, auths, + unlinked, onUnlinkExternalAuth, onValidateExternalAuth, }: UserExternalAuthSettingsPageViewProps): JSX.Element => { @@ -48,27 +49,14 @@ export const UserExternalAuthSettingsPageView = ({ return ; } - if (!auths) { - // TODO: Do loading? - return <>; + if (isLoading || !auths) { + return ; } return ( <> - +
Application @@ -87,65 +75,19 @@ export const UserExternalAuthSettingsPageView = ({ )) || auths.providers?.map((app: ExternalAuthLinkProvider) => { - const name = app.id || app.type; - const link = auths.links.find((l) => l.provider_id === name); - const authURL = "/external-auth/" + app.id; return ( - - - - ) - } - /> - - - {}} - > - - - {link && ( - - - - - - { - onValidateExternalAuth(app.id); - }} - > - Test Validate… - - - { - onUnlinkExternalAuth(app.id); - }} - > - Unlink… - - - - )} - - + l.provider_id === app.id)} + onUnlinkExternalAuth={() => { + onUnlinkExternalAuth(app.id); + }} + onValidateExternalAuth={() => { + onValidateExternalAuth(app.id); + }} + /> ); })} @@ -154,3 +96,145 @@ export const UserExternalAuthSettingsPageView = ({ ); }; + +interface ExternalAuthRowProps { + app: ExternalAuthLinkProvider; + link?: ExternalAuthLink; + unlinked: number; + onUnlinkExternalAuth: () => void; + onValidateExternalAuth: () => void; +} + +const ExternalAuthRow = ({ + app, + unlinked, + link, + onUnlinkExternalAuth, + onValidateExternalAuth, +}: ExternalAuthRowProps): JSX.Element => { + const name = app.id || app.type; + const authURL = "/external-auth/" + app.id; + + const { + externalAuth, + externalAuthPollingState, + refetch, + startPollingExternalAuth, + } = useExternalAuth(app.id, unlinked); + + const authenticated = externalAuth + ? externalAuth.authenticated + : link?.authenticated ?? false; + + return ( + + + + ) + } + /> + + + + + + {(link || externalAuth?.authenticated) && ( + + + + + + { + onValidateExternalAuth(); + // This is kinda jank. It does a refetch of the thing + // it just validated... But we need to refetch to update the + // login button. And the 'onValidateExternalAuth' does the + // message display. + await refetch(); + }} + > + Test Validate… + + + { + onUnlinkExternalAuth(); + await refetch(); + }} + > + Unlink… + + + + )} + + + ); +}; + +// useExternalAuth handles the polling of the auth to update the button. +const useExternalAuth = (providerID: string, unlinked: number) => { + const [externalAuthPollingState, setExternalAuthPollingState] = + useState("idle"); + + const startPollingExternalAuth = useCallback(() => { + setExternalAuthPollingState("polling"); + }, []); + + const { data: externalAuth, refetch } = useQuery({ + ...userExternalAuth(providerID), + refetchInterval: externalAuthPollingState === "polling" ? 1000 : false, + }); + + const signedIn = externalAuth?.authenticated; + + useEffect(() => { + if (unlinked > 0) { + void refetch(); + } + }, [unlinked]); + + useEffect(() => { + if (signedIn) { + setExternalAuthPollingState("idle"); + return; + } + + if (externalAuthPollingState !== "polling") { + return; + } + + // Poll for a maximum of one minute + const quitPolling = setTimeout( + () => setExternalAuthPollingState("abandoned"), + 60_000, + ); + return () => { + clearTimeout(quitPolling); + }; + }, [externalAuthPollingState, signedIn]); + + return { + startPollingExternalAuth, + externalAuth, + externalAuthPollingState, + refetch, + }; +}; From ea2f65ba53c3de0b97d46a4b2bd6586f1fe2b88c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 4 Dec 2023 16:46:59 -0600 Subject: [PATCH 6/8] add stories --- ...erExternalAuthSettingsPageView.stories.tsx | 82 +++++++++++-------- .../UserExternalAuthSettingsPageView.tsx | 2 +- site/src/testHelpers/entities.ts | 20 +++++ 3 files changed, 70 insertions(+), 34 deletions(-) diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx index 31084048549c0..3419f0f49a69c 100644 --- a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx +++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx @@ -1,36 +1,52 @@ -// import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView"; -// import type { Meta, StoryObj } from "@storybook/react"; +import { + MockGithubAuthLink, + MockGithubExternalProvider, +} from "testHelpers/entities"; +import { UserExternalAuthSettingsPageView } from "./UserExternalAuthSettingsPageView"; +import type { Meta, StoryObj } from "@storybook/react"; -// const meta: Meta = { -// title: "pages/DeploySettingsPage/ExternalAuthSettingsPageView", -// component: ExternalAuthSettingsPageView, -// args: { -// config: { -// external_auth: [ -// { -// id: "0000-1111", -// type: "GitHub", -// client_id: "client_id", -// regex: "regex", -// auth_url: "", -// token_url: "", -// validate_url: "", -// app_install_url: "https://github.com/apps/coder/installations/new", -// app_installations_url: "", -// no_refresh: false, -// scopes: [], -// extra_token_keys: [], -// device_flow: true, -// device_code_url: "", -// display_icon: "", -// display_name: "GitHub", -// }, -// ], -// }, -// }, -// }; +const meta: Meta = { + title: "pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView", + component: UserExternalAuthSettingsPageView, + args: { + isLoading: false, + getAuthsError: undefined, + unlinked: 0, + auths: { + providers: [], + links: [], + }, + onUnlinkExternalAuth: () => {}, + onValidateExternalAuth: () => {}, + }, +}; -// export default meta; -// type Story = StoryObj; +export default meta; +type Story = StoryObj; -// export const Page: Story = {}; +export const NoProviders: Story = {}; + +export const Authenticated: Story = { + args: { + ...meta.args, + auths: { + providers: [MockGithubExternalProvider], + links: [MockGithubAuthLink], + }, + }, +}; + +export const UnAuthenticated: Story = { + args: { + ...meta.args, + auths: { + providers: [MockGithubExternalProvider], + links: [ + { + ...MockGithubAuthLink, + authenticated: false, + }, + ], + }, + }, +}; diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx index 855b09edd68fe..96ef1066f43b7 100644 --- a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx +++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx @@ -209,7 +209,7 @@ const useExternalAuth = (providerID: string, unlinked: number) => { if (unlinked > 0) { void refetch(); } - }, [unlinked]); + }, [refetch, unlinked]); useEffect(() => { if (signedIn) { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 48ce84c7bcf2b..c2e56b5c3ff0e 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2931,3 +2931,23 @@ export const DeploymentHealthUnhealthy: TypesGen.HealthcheckReport = { }, }, }; + +export const MockGithubExternalProvider: TypesGen.ExternalAuthLinkProvider = { + id: "github", + type: "github", + device: false, + display_icon: "/icon/github.svg", + display_name: "GitHub", + allow_refresh: true, + allow_validate: true, +}; + +export const MockGithubAuthLink: TypesGen.ExternalAuthLink = { + provider_id: "github", + created_at: "", + updated_at: "", + has_refresh_token: true, + expires: "", + authenticated: true, + validate_error: "", +}; From cc7cd5251b17eda79ba5b777968d62563d432455 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 4 Dec 2023 16:50:41 -0600 Subject: [PATCH 7/8] fmt --- site/src/api/api.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index f681a34add6f4..d1a7bcfe3dbfc 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -939,11 +939,11 @@ export const exchangeExternalAuthDevice = async ( return resp.data; }; -export const getUserExternalAuthProviders = async ( -): Promise => { - const resp = await axios.get(`/api/v2/external-auth`); - return resp.data; -}; +export const getUserExternalAuthProviders = + async (): Promise => { + const resp = await axios.get(`/api/v2/external-auth`); + return resp.data; + }; export const unlinkExternalAuthProvider = async ( provider: string, From ad5587ab4b810407015db4597b6160b534dd4b2c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 5 Dec 2023 15:12:20 -0600 Subject: [PATCH 8/8] PR suggestions --- site/src/api/queries/externalauth.ts | 3 -- .../UserExternalAuthSettingsPage.tsx | 28 ++++++++++--------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/site/src/api/queries/externalauth.ts b/site/src/api/queries/externalauth.ts index ab9671dbfd023..684135db75d13 100644 --- a/site/src/api/queries/externalauth.ts +++ b/site/src/api/queries/externalauth.ts @@ -27,9 +27,6 @@ export const userExternalAuth = (providerID: string) => { export const validateExternalAuth = (_: QueryClient) => { return { mutationFn: API.getExternalAuthProvider, - onSuccess: async () => { - // No invalidation needed. - }, }; }; diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx index 4fd9e5c6741cc..ced75156bd0c5 100644 --- a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx +++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx @@ -30,8 +30,6 @@ const UserExternalAuthSettingsPage: FC = () => { ...mutateParams, onSuccess: async () => { await mutateParams.onSuccess(); - await refetch(); - setUnlinked(unlinked + 1); }, }); @@ -49,17 +47,14 @@ const UserExternalAuthSettingsPage: FC = () => { }} onValidateExternalAuth={async (providerID: string) => { try { - await validateAppMutation.mutateAsync(providerID, { - onSuccess: (data) => { - if (data.authenticated) { - displaySuccess("Application link is valid."); - } else { - displayError( - "Application link is not valid. Please unlink the application and reauthenticate.", - ); - } - }, - }); + const data = await validateAppMutation.mutateAsync(providerID); + if (data.authenticated) { + displaySuccess("Application link is valid."); + } else { + displayError( + "Application link is not valid. Please unlink the application and reauthenticate.", + ); + } } catch (e) { displayError( getErrorMessage(e, "Error validating application link."), @@ -83,7 +78,14 @@ const UserExternalAuthSettingsPage: FC = () => { onConfirm={async () => { try { await unlinkAppMutation.mutateAsync(appToUnlink!); + // setAppToUnlink closes the modal setAppToUnlink(undefined); + // refetch repopulates the external auth data + await refetch(); + // this tells our child components to refetch their data + // as at least 1 provider was unlinked. + setUnlinked(unlinked + 1); + displaySuccess("Successfully unlinked the oauth2 application."); } catch (e) { displayError(getErrorMessage(e, "Error unlinking application."));