diff --git a/coderd/coderd.go b/coderd/coderd.go index 4513b2c86360a..dbb5deb7fe9aa 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -82,8 +82,6 @@ func New(options *Options) *API { apiKeyMiddleware := httpmw.ExtractAPIKey(options.Database, &httpmw.OAuth2Configs{ Github: options.GithubOAuth2Config, }) - // TODO: @emyrk we should just move this into 'ExtractAPIKey'. - authRolesMiddleware := httpmw.ExtractUserRoles(options.Database) r.Use( func(next http.Handler) http.Handler { @@ -125,7 +123,6 @@ func New(options *Options) *API { r.Route("/files", func(r chi.Router) { r.Use( apiKeyMiddleware, - authRolesMiddleware, // This number is arbitrary, but reading/writing // file content is expensive so it should be small. httpmw.RateLimitPerMinute(12), @@ -136,14 +133,12 @@ func New(options *Options) *API { r.Route("/provisionerdaemons", func(r chi.Router) { r.Use( apiKeyMiddleware, - authRolesMiddleware, ) r.Get("/", api.provisionerDaemons) }) r.Route("/organizations", func(r chi.Router) { r.Use( apiKeyMiddleware, - authRolesMiddleware, ) r.Post("/", api.postOrganizations) r.Route("/{organization}", func(r chi.Router) { @@ -179,7 +174,7 @@ func New(options *Options) *API { }) }) r.Route("/parameters/{scope}/{id}", func(r chi.Router) { - r.Use(apiKeyMiddleware, authRolesMiddleware) + r.Use(apiKeyMiddleware) r.Post("/", api.postParameter) r.Get("/", api.parameters) r.Route("/{name}", func(r chi.Router) { @@ -189,7 +184,6 @@ func New(options *Options) *API { r.Route("/templates/{template}", func(r chi.Router) { r.Use( apiKeyMiddleware, - authRolesMiddleware, httpmw.ExtractTemplateParam(options.Database), ) @@ -204,7 +198,6 @@ func New(options *Options) *API { r.Route("/templateversions/{templateversion}", func(r chi.Router) { r.Use( apiKeyMiddleware, - authRolesMiddleware, httpmw.ExtractTemplateVersionParam(options.Database), ) @@ -229,7 +222,6 @@ func New(options *Options) *API { r.Group(func(r chi.Router) { r.Use( apiKeyMiddleware, - authRolesMiddleware, ) r.Post("/", api.postUser) r.Get("/", api.users) @@ -244,7 +236,7 @@ func New(options *Options) *API { r.Put("/profile", api.putUserProfile) r.Route("/status", func(r chi.Router) { r.Put("/suspend", api.putUserStatus(database.UserStatusSuspended)) - r.Put("/active", api.putUserStatus(database.UserStatusActive)) + r.Put("/activate", api.putUserStatus(database.UserStatusActive)) }) r.Route("/password", func(r chi.Router) { r.Put("/", api.putUserPassword) @@ -292,7 +284,6 @@ func New(options *Options) *API { r.Route("/workspaceresources/{workspaceresource}", func(r chi.Router) { r.Use( apiKeyMiddleware, - authRolesMiddleware, httpmw.ExtractWorkspaceResourceParam(options.Database), httpmw.ExtractWorkspaceParam(options.Database), ) @@ -301,7 +292,6 @@ func New(options *Options) *API { r.Route("/workspaces", func(r chi.Router) { r.Use( apiKeyMiddleware, - authRolesMiddleware, ) r.Get("/", api.workspaces) r.Route("/{workspace}", func(r chi.Router) { @@ -327,7 +317,6 @@ func New(options *Options) *API { r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { r.Use( apiKeyMiddleware, - authRolesMiddleware, httpmw.ExtractWorkspaceBuildParam(options.Database), httpmw.ExtractWorkspaceParam(options.Database), ) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 8d9edc6f02523..aad1b333a5274 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -231,11 +231,13 @@ func (q *fakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams users = tmp } - if params.Status != "" { + if len(params.Status) > 0 { usersFilteredByStatus := make([]database.User, 0, len(users)) for i, user := range users { - if params.Status == string(user.Status) { - usersFilteredByStatus = append(usersFilteredByStatus, users[i]) + for _, status := range params.Status { + if user.Status == status { + usersFilteredByStatus = append(usersFilteredByStatus, users[i]) + } } } users = usersFilteredByStatus @@ -302,6 +304,7 @@ func (q *fakeQuerier) GetAllUserRoles(_ context.Context, userID uuid.UUID) (data return database.GetAllUserRolesRow{ ID: userID, Username: user.Username, + Status: user.Status, Roles: roles, }, nil } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 8724fbbc8e474..29f3eb84f3272 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2091,7 +2091,9 @@ func (q *sqlQuerier) UpdateTemplateVersionDescriptionByJobID(ctx context.Context const getAllUserRoles = `-- name: GetAllUserRoles :one SELECT -- username is returned just to help for logging purposes - id, username, array_cat(users.rbac_roles, organization_members.roles) :: text[] AS roles + -- status is used to enforce 'suspended' users, as all roles are ignored + -- when suspended. + id, username, status, array_cat(users.rbac_roles, organization_members.roles) :: text[] AS roles FROM users LEFT JOIN organization_members @@ -2101,15 +2103,21 @@ WHERE ` type GetAllUserRolesRow struct { - ID uuid.UUID `db:"id" json:"id"` - Username string `db:"username" json:"username"` - Roles []string `db:"roles" json:"roles"` + ID uuid.UUID `db:"id" json:"id"` + Username string `db:"username" json:"username"` + Status UserStatus `db:"status" json:"status"` + Roles []string `db:"roles" json:"roles"` } func (q *sqlQuerier) GetAllUserRoles(ctx context.Context, userID uuid.UUID) (GetAllUserRolesRow, error) { row := q.db.QueryRowContext(ctx, getAllUserRoles, userID) var i GetAllUserRolesRow - err := row.Scan(&i.ID, &i.Username, pq.Array(&i.Roles)) + err := row.Scan( + &i.ID, + &i.Username, + &i.Status, + pq.Array(&i.Roles), + ) return i, err } @@ -2218,17 +2226,19 @@ WHERE WHEN $2 :: text != '' THEN ( email LIKE concat('%', $2, '%') OR username LIKE concat('%', $2, '%') - ) + ) ELSE true END -- Filter by status AND CASE -- @status needs to be a text because it can be empty, If it was -- user_status enum, it would not. - WHEN $3 :: text != '' THEN ( - status = $3 :: user_status + WHEN cardinality($3 :: user_status[]) > 0 THEN ( + status = ANY($3 :: user_status[]) ) - ELSE true + ELSE + -- Only show active by default + status = 'active' END -- End of filters ORDER BY @@ -2241,18 +2251,18 @@ LIMIT ` type GetUsersParams struct { - AfterID uuid.UUID `db:"after_id" json:"after_id"` - Search string `db:"search" json:"search"` - Status string `db:"status" json:"status"` - OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` - LimitOpt int32 `db:"limit_opt" json:"limit_opt"` + AfterID uuid.UUID `db:"after_id" json:"after_id"` + Search string `db:"search" json:"search"` + Status []UserStatus `db:"status" json:"status"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` } func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, error) { rows, err := q.db.QueryContext(ctx, getUsers, arg.AfterID, arg.Search, - arg.Status, + pq.Array(arg.Status), arg.OffsetOpt, arg.LimitOpt, ) diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index d9f68ec440bc3..a4a8bcfc7bec6 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -101,17 +101,19 @@ WHERE WHEN @search :: text != '' THEN ( email LIKE concat('%', @search, '%') OR username LIKE concat('%', @search, '%') - ) + ) ELSE true END -- Filter by status AND CASE -- @status needs to be a text because it can be empty, If it was -- user_status enum, it would not. - WHEN @status :: text != '' THEN ( - status = @status :: user_status + WHEN cardinality(@status :: user_status[]) > 0 THEN ( + status = ANY(@status :: user_status[]) ) - ELSE true + ELSE + -- Only show active by default + status = 'active' END -- End of filters ORDER BY @@ -135,7 +137,9 @@ WHERE -- name: GetAllUserRoles :one SELECT -- username is returned just to help for logging purposes - id, username, array_cat(users.rbac_roles, organization_members.roles) :: text[] AS roles + -- status is used to enforce 'suspended' users, as all roles are ignored + -- when suspended. + id, username, status, array_cat(users.rbac_roles, organization_members.roles) :: text[] AS roles FROM users LEFT JOIN organization_members diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index abf55128d55d7..1e4098c0be431 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -175,7 +175,27 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) h } } - ctx := context.WithValue(r.Context(), apiKeyContextKey{}, key) + // If the key is valid, we also fetch the user roles and status. + // The roles are used for RBAC authorize checks, and the status + // is to block 'suspended' users from accessing the platform. + roles, err := db.GetAllUserRoles(r.Context(), key.UserID) + if err != nil { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: "roles not found", + }) + return + } + + if roles.Status != database.UserStatusActive { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: fmt.Sprintf("user is not active (status = %q), contact an admin to reactivate your account", roles.Status), + }) + return + } + + ctx := r.Context() + ctx = context.WithValue(ctx, apiKeyContextKey{}, key) + ctx = context.WithValue(ctx, userRolesKey{}, roles) next.ServeHTTP(rw, r.WithContext(ctx)) }) } diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index 3be2877426134..c841798094b08 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/require" "golang.org/x/oauth2" @@ -128,6 +129,7 @@ func TestAPIKey(t *testing.T) { id, secret = randomAPIKeyParts() r = httptest.NewRequest("GET", "/", nil) rw = httptest.NewRecorder() + user = createUser(r.Context(), t, db) ) r.AddCookie(&http.Cookie{ Name: httpmw.SessionTokenKey, @@ -139,6 +141,7 @@ func TestAPIKey(t *testing.T) { _, err := db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ ID: id, HashedSecret: hashed[:], + UserID: user.ID, }) require.NoError(t, err) httpmw.ExtractAPIKey(db, nil)(successHandler).ServeHTTP(rw, r) @@ -155,6 +158,7 @@ func TestAPIKey(t *testing.T) { hashed = sha256.Sum256([]byte(secret)) r = httptest.NewRequest("GET", "/", nil) rw = httptest.NewRecorder() + user = createUser(r.Context(), t, db) ) r.AddCookie(&http.Cookie{ Name: httpmw.SessionTokenKey, @@ -164,6 +168,7 @@ func TestAPIKey(t *testing.T) { _, err := db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ ID: id, HashedSecret: hashed[:], + UserID: user.ID, }) require.NoError(t, err) httpmw.ExtractAPIKey(db, nil)(successHandler).ServeHTTP(rw, r) @@ -180,6 +185,7 @@ func TestAPIKey(t *testing.T) { hashed = sha256.Sum256([]byte(secret)) r = httptest.NewRequest("GET", "/", nil) rw = httptest.NewRecorder() + user = createUser(r.Context(), t, db) ) r.AddCookie(&http.Cookie{ Name: httpmw.SessionTokenKey, @@ -190,6 +196,7 @@ func TestAPIKey(t *testing.T) { ID: id, HashedSecret: hashed[:], ExpiresAt: database.Now().AddDate(0, 0, 1), + UserID: user.ID, }) require.NoError(t, err) httpmw.ExtractAPIKey(db, nil)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { @@ -217,6 +224,7 @@ func TestAPIKey(t *testing.T) { hashed = sha256.Sum256([]byte(secret)) r = httptest.NewRequest("GET", "/", nil) rw = httptest.NewRecorder() + user = createUser(r.Context(), t, db) ) q := r.URL.Query() q.Add(httpmw.SessionTokenKey, fmt.Sprintf("%s-%s", id, secret)) @@ -226,6 +234,7 @@ func TestAPIKey(t *testing.T) { ID: id, HashedSecret: hashed[:], ExpiresAt: database.Now().AddDate(0, 0, 1), + UserID: user.ID, }) require.NoError(t, err) httpmw.ExtractAPIKey(db, nil)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { @@ -248,6 +257,7 @@ func TestAPIKey(t *testing.T) { hashed = sha256.Sum256([]byte(secret)) r = httptest.NewRequest("GET", "/", nil) rw = httptest.NewRecorder() + user = createUser(r.Context(), t, db) ) r.AddCookie(&http.Cookie{ Name: httpmw.SessionTokenKey, @@ -259,6 +269,7 @@ func TestAPIKey(t *testing.T) { HashedSecret: hashed[:], LastUsed: database.Now().AddDate(0, 0, -1), ExpiresAt: database.Now().AddDate(0, 0, 1), + UserID: user.ID, }) require.NoError(t, err) httpmw.ExtractAPIKey(db, nil)(successHandler).ServeHTTP(rw, r) @@ -281,6 +292,7 @@ func TestAPIKey(t *testing.T) { hashed = sha256.Sum256([]byte(secret)) r = httptest.NewRequest("GET", "/", nil) rw = httptest.NewRecorder() + user = createUser(r.Context(), t, db) ) r.AddCookie(&http.Cookie{ Name: httpmw.SessionTokenKey, @@ -292,6 +304,7 @@ func TestAPIKey(t *testing.T) { HashedSecret: hashed[:], LastUsed: database.Now(), ExpiresAt: database.Now().Add(time.Minute), + UserID: user.ID, }) require.NoError(t, err) httpmw.ExtractAPIKey(db, nil)(successHandler).ServeHTTP(rw, r) @@ -314,6 +327,7 @@ func TestAPIKey(t *testing.T) { hashed = sha256.Sum256([]byte(secret)) r = httptest.NewRequest("GET", "/", nil) rw = httptest.NewRecorder() + user = createUser(r.Context(), t, db) ) r.AddCookie(&http.Cookie{ Name: httpmw.SessionTokenKey, @@ -326,6 +340,7 @@ func TestAPIKey(t *testing.T) { LoginType: database.LoginTypeGithub, LastUsed: database.Now(), ExpiresAt: database.Now().AddDate(0, 0, 1), + UserID: user.ID, }) require.NoError(t, err) httpmw.ExtractAPIKey(db, nil)(successHandler).ServeHTTP(rw, r) @@ -348,6 +363,7 @@ func TestAPIKey(t *testing.T) { hashed = sha256.Sum256([]byte(secret)) r = httptest.NewRequest("GET", "/", nil) rw = httptest.NewRecorder() + user = createUser(r.Context(), t, db) ) r.AddCookie(&http.Cookie{ Name: httpmw.SessionTokenKey, @@ -360,6 +376,7 @@ func TestAPIKey(t *testing.T) { LoginType: database.LoginTypeGithub, LastUsed: database.Now(), OAuthExpiry: database.Now().AddDate(0, 0, -1), + UserID: user.ID, }) require.NoError(t, err) token := &oauth2.Token{ @@ -387,6 +404,20 @@ func TestAPIKey(t *testing.T) { }) } +func createUser(ctx context.Context, t *testing.T, db database.Store) database.User { + user, err := db.InsertUser(ctx, database.InsertUserParams{ + ID: uuid.New(), + Email: "email@coder.com", + Username: "username", + HashedPassword: []byte{}, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + RBACRoles: []string{}, + }) + require.NoError(t, err, "create user") + return user +} + type oauth2Config struct { tokenSource oauth2TokenSource } diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index 0e2466da403cd..279c037e2ae92 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -84,7 +84,6 @@ func TestExtractUserRoles(t *testing.T) { ) rtr.Use( httpmw.ExtractAPIKey(db, &httpmw.OAuth2Configs{}), - httpmw.ExtractUserRoles(db), ) rtr.Get("/", func(_ http.ResponseWriter, r *http.Request) { roles := httpmw.UserRoles(r) diff --git a/coderd/users.go b/coderd/users.go index 1523cd4d38095..f1b82ddb23e01 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "github.com/go-chi/chi/v5" @@ -105,10 +106,27 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { func (api *API) users(rw http.ResponseWriter, r *http.Request) { var ( - searchName = r.URL.Query().Get("search") - statusFilter = r.URL.Query().Get("status") + searchName = r.URL.Query().Get("search") + statusFilters = r.URL.Query().Get("status") ) + statuses := make([]database.UserStatus, 0) + + if statusFilters != "" { + // Split on commas if present to account for it being a list + for _, filter := range strings.Split(statusFilters, ",") { + switch database.UserStatus(filter) { + case database.UserStatusSuspended, database.UserStatusActive: + statuses = append(statuses, database.UserStatus(filter)) + default: + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("%q is not a valid user status", filter), + }) + return + } + } + } + // Reading all users across the site. if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceUser) { return @@ -124,7 +142,7 @@ func (api *API) users(rw http.ResponseWriter, r *http.Request) { OffsetOpt: int32(paginationParams.Offset), LimitOpt: int32(paginationParams.Limit), Search: searchName, - Status: statusFilter, + Status: statuses, }) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusOK, []codersdk.User{}) @@ -605,7 +623,15 @@ func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) { // This message is the same as above to remove ease in detecting whether // users are registered or not. Attackers still could with a timing attack. httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: "invalid email or password", + Message: "Incorrect email or password.", + }) + return + } + + // If the user logged into a suspended account, reject the login request. + if user.Status != database.UserStatusActive { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: "You are suspended, contact an admin to reactivate your account", }) return } diff --git a/coderd/users_test.go b/coderd/users_test.go index 344de9c685f3e..17d8afa9578f6 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -84,6 +84,35 @@ func TestPostLogin(t *testing.T) { require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) }) + t.Run("Suspended", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + memberUser, err := member.User(context.Background(), codersdk.Me) + require.NoError(t, err, "fetch member user") + + _, err = client.UpdateUserStatus(context.Background(), memberUser.Username, codersdk.UserStatusSuspended) + require.NoError(t, err, "suspend member") + + // Test an existing session + _, err = member.User(context.Background(), codersdk.Me) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "contact an admin") + + // Test a new session + _, err = client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ + Email: memberUser.Email, + Password: "testpass", + }) + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "suspended") + }) + t.Run("Success", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/codersdk/users.go b/codersdk/users.go index c41b660b4b3ec..658f9b9dbdb0c 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -218,7 +218,7 @@ func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserS path := fmt.Sprintf("/api/v2/users/%s/status/", user) switch status { case UserStatusActive: - path += "active" + path += "activate" case UserStatusSuspended: path += "suspend" default: diff --git a/scripts/develop.sh b/scripts/develop.sh index 41418e4ae3f09..745446db0140a 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -24,5 +24,10 @@ export CODER_DEV_ADMIN_PASSWORD=password trap 'kill 0' SIGINT CODERV2_HOST=http://127.0.0.1:3000 INSPECT_XSTATE=true yarn --cwd=./site dev & go run -tags embed cmd/coder/main.go server --dev --tunnel=true & + + # Just a minor sleep to ensure the first user was created to make the member. + sleep 2 + # || yes to always exit code 0. If this fails, whelp. + go run cmd/coder/main.go users create --email=member@coder.com --username=member --password="${CODER_DEV_ADMIN_PASSWORD}" || yes wait ) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0697200681513..46d3d50dbfc6d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -63,7 +63,7 @@ export const getApiKey = async (): Promise => { } export const getUsers = async (): Promise => { - const response = await axios.get("/api/v2/users?status=active") + const response = await axios.get("/api/v2/users?status=active,suspended") return response.data } @@ -218,6 +218,11 @@ export const updateProfile = async ( return response.data } +export const activateUser = async (userId: TypesGen.User["id"]): Promise => { + const response = await axios.put(`/api/v2/users/${userId}/status/activate`) + return response.data +} + export const suspendUser = async (userId: TypesGen.User["id"]): Promise => { const response = await axios.put(`/api/v2/users/${userId}/status/suspend`) return response.data diff --git a/site/src/components/SignInForm/SignInForm.tsx b/site/src/components/SignInForm/SignInForm.tsx index 9ec486221a03d..502726c09fd1e 100644 --- a/site/src/components/SignInForm/SignInForm.tsx +++ b/site/src/components/SignInForm/SignInForm.tsx @@ -110,7 +110,7 @@ export const SignInForm: React.FC = ({ type="password" variant="outlined" /> - {authErrorMessage && {Language.authErrorMessage}} + {authErrorMessage && {authErrorMessage}} {methodsErrorMessage && {Language.methodsErrorMessage}}
diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index 1ed0552caa976..f71dfde32b770 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -18,8 +18,10 @@ export const Language = { emptyMessage: "No users found", usernameLabel: "User", suspendMenuItem: "Suspend", + activateMenuItem: "Activate", resetPasswordMenuItem: "Reset password", rolesLabel: "Roles", + statusLabel: "Status", } export interface UsersTableProps { @@ -48,6 +50,7 @@ export const UsersTable: React.FC = ({ {Language.usernameLabel} + {Language.statusLabel} {Language.rolesLabel} {/* 1% is a trick to make the table cell width fit the content */} {canEditUsers && } @@ -62,6 +65,7 @@ export const UsersTable: React.FC = ({ + {u.status} {canEditUsers ? ( = ({ )} diff --git a/site/src/pages/LoginPage/LoginPage.test.tsx b/site/src/pages/LoginPage/LoginPage.test.tsx index f81c3ebddeae6..ed54435abea48 100644 --- a/site/src/pages/LoginPage/LoginPage.test.tsx +++ b/site/src/pages/LoginPage/LoginPage.test.tsx @@ -31,7 +31,7 @@ describe("LoginPage", () => { server.use( // Make login fail rest.post("/api/v2/users/login", async (req, res, ctx) => { - return res(ctx.status(500), ctx.json({ message: "nope" })) + return res(ctx.status(500), ctx.json({ message: Language.authErrorMessage })) }), ) diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index fe4bcedae5646..cc3d8eb6e6efb 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -2,6 +2,7 @@ import { makeStyles } from "@material-ui/core/styles" import { useActor } from "@xstate/react" import React, { useContext } from "react" import { Navigate, useLocation } from "react-router-dom" +import { isApiError } from "../../api/errors" import { Footer } from "../../components/Footer/Footer" import { SignInForm } from "../../components/SignInForm/SignInForm" import { retrieveRedirect } from "../../util/redirect" @@ -33,7 +34,9 @@ export const LoginPage: React.FC = () => { const [authState, authSend] = useActor(xServices.authXService) const isLoading = authState.hasTag("loading") const redirectTo = retrieveRedirect(location.search) - const authErrorMessage = authState.context.authError ? (authState.context.authError as Error).message : undefined + const authErrorMessage = isApiError(authState.context.authError) + ? authState.context.authError.response.data.message + : undefined const getMethodsError = authState.context.getMethodsError ? (authState.context.getMethodsError as Error).message : undefined diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 3e634fc8b58d8..4c5a951d34a86 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -1,3 +1,4 @@ +import { AxiosError } from "axios" import { assign, createMachine } from "xstate" import * as API from "../../api/api" import * as TypesGen from "../../api/typesGenerated" @@ -48,7 +49,7 @@ type Permissions = Record export interface AuthContext { getUserError?: Error | unknown getMethodsError?: Error | unknown - authError?: Error | unknown + authError?: Error | AxiosError | unknown updateProfileError?: Error | unknown me?: TypesGen.User methods?: TypesGen.AuthMethods