mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat: add break-glass user email update (#29256)
Adds an owner-only experimental API and hidden CLI command for break-glass user email corrections. The operation verifies the existing email, updates it transactionally, clears outstanding password-reset state, revokes the target user's Coder sessions and API tokens, and records successful and failed authenticated attempts in the audit log. External-auth links remain unchanged. Generated by Coder Agents.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) updateUserEmail() *serpent.Command {
|
||||
var (
|
||||
oldEmail string
|
||||
newEmail string
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "update-user-email",
|
||||
Short: "Update a user's email address (break-glass; experimental)",
|
||||
Hidden: true,
|
||||
Options: serpent.OptionSet{
|
||||
{
|
||||
Flag: "old-email",
|
||||
Description: "Current email address of the user to update.",
|
||||
Required: true,
|
||||
Value: serpent.StringOf(&oldEmail),
|
||||
},
|
||||
{
|
||||
Flag: "new-email",
|
||||
Description: "New email address to assign to the user.",
|
||||
Required: true,
|
||||
Value: serpent.StringOf(&newEmail),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
if oldEmail == "" {
|
||||
return xerrors.Errorf("--old-email must not be blank")
|
||||
}
|
||||
if newEmail == "" {
|
||||
return xerrors.Errorf("--new-email must not be blank")
|
||||
}
|
||||
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout,
|
||||
"This will update the email address for the account currently using %q to %q.\n"+
|
||||
"All Coder sessions and API tokens for that user will be revoked.\n"+
|
||||
"If the user logs in with an external identity provider, it may overwrite the email when the user next signs in.\n",
|
||||
oldEmail, newEmail,
|
||||
)
|
||||
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Confirm email update?",
|
||||
IsConfirm: true,
|
||||
Default: cliui.ConfirmNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.UpdateUserEmail(inv.Context(), codersdk.UpdateUserEmailRequest{
|
||||
OldEmail: oldEmail,
|
||||
NewEmail: newEmail,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update user email: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Updated user email from %s to %s.\n", oldEmail, newEmail)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func TestUpdateUserEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("CommandReachable", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := getRoot(t)
|
||||
var found *serpent.Command
|
||||
root.Walk(func(cmd *serpent.Command) {
|
||||
if cmd.Name() == "update-user-email" {
|
||||
found = cmd
|
||||
}
|
||||
})
|
||||
require.NotNil(t, found, "update-user-email command not found under exp")
|
||||
require.True(t, found.Hidden, "command should be hidden")
|
||||
})
|
||||
|
||||
t.Run("MissingOldEmail", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv, _ := clitest.New(t, "exp", "update-user-email", "--new-email", "new@example.com")
|
||||
err := inv.Run()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "old-email")
|
||||
})
|
||||
|
||||
t.Run("MissingNewEmail", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv, _ := clitest.New(t, "exp", "update-user-email", "--old-email", "old@example.com")
|
||||
err := inv.Run()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "new-email")
|
||||
})
|
||||
|
||||
t.Run("DeclinePrompt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Use a channel closed by the handler to detect whether the API is called.
|
||||
apiCalled := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
close(apiCalled)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
client := codersdk.New(must(url.Parse(srv.URL)))
|
||||
inv, root := clitest.New(t, "exp", "update-user-email",
|
||||
"--old-email", "old@example.com",
|
||||
"--new-email", "new@example.com",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
inv.Stdin = strings.NewReader("no\n")
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
done := make(chan struct{})
|
||||
var runErr error
|
||||
go func() {
|
||||
defer close(done)
|
||||
runErr = inv.Run()
|
||||
}()
|
||||
|
||||
testutil.TryReceive(ctx, t, done)
|
||||
require.ErrorIs(t, runErr, cliui.ErrCanceled)
|
||||
|
||||
// Verify the API was not called after the command returned.
|
||||
select {
|
||||
case <-apiCalled:
|
||||
t.Fatal("API should not be called when prompt is declined")
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
// AcceptPrompt runs a full end-to-end test against a real Coder server: it
|
||||
// creates a second user, invokes the CLI as an admin, confirms the prompt,
|
||||
// and asserts both the command output and the actual stored email.
|
||||
t.Run("AcceptPrompt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
_, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
oldEmail := member.Email
|
||||
newEmail := "updated-" + oldEmail
|
||||
|
||||
inv, root := clitest.New(t, "exp", "update-user-email",
|
||||
"--old-email", oldEmail,
|
||||
"--new-email", newEmail,
|
||||
)
|
||||
//nolint:gocritic // This break-glass command is restricted to deployment owners.
|
||||
clitest.SetupConfig(t, client, root)
|
||||
inv.Stdin = strings.NewReader("yes\n")
|
||||
|
||||
var outBuf bytes.Buffer
|
||||
inv.Stdout = &outBuf
|
||||
|
||||
require.NoError(t, inv.Run())
|
||||
|
||||
out := outBuf.String()
|
||||
require.Contains(t, out, oldEmail)
|
||||
require.Contains(t, out, newEmail)
|
||||
require.Contains(t, out, "sessions and API tokens")
|
||||
require.Contains(t, out, "external identity provider")
|
||||
require.Contains(t, out, "Updated user email from "+oldEmail+" to "+newEmail+".")
|
||||
|
||||
// Verify the email was actually persisted.
|
||||
updated, err := client.User(ctx, member.ID.String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, newEmail, updated.Email)
|
||||
})
|
||||
|
||||
t.Run("YesSkipsPrompt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotBody codersdk.UpdateUserEmailRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
assert.NoError(t, err)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
client := codersdk.New(must(url.Parse(srv.URL)))
|
||||
inv, root := clitest.New(t, "exp", "update-user-email",
|
||||
"--old-email", "old@example.com",
|
||||
"--new-email", "new@example.com",
|
||||
"--yes",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
var outBuf bytes.Buffer
|
||||
inv.Stdout = &outBuf
|
||||
|
||||
require.NoError(t, inv.Run())
|
||||
require.Contains(t, outBuf.String(), "Updated user email from old@example.com to new@example.com.")
|
||||
require.Equal(t, "old@example.com", gotBody.OldEmail)
|
||||
require.Equal(t, "new@example.com", gotBody.NewEmail)
|
||||
})
|
||||
|
||||
t.Run("APIError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
client := codersdk.New(must(url.Parse(srv.URL)))
|
||||
inv, root := clitest.New(t, "exp", "update-user-email",
|
||||
"--old-email", "old@example.com",
|
||||
"--new-email", "new@example.com",
|
||||
"--yes",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
err := inv.Run()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "update user email")
|
||||
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode())
|
||||
require.Contains(t, sdkErr.Message, "internal server error")
|
||||
})
|
||||
}
|
||||
@@ -171,6 +171,7 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command {
|
||||
r.promptExample(),
|
||||
r.rptyCommand(),
|
||||
r.syncCommand(),
|
||||
r.updateUserEmail(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+53
@@ -246,6 +246,42 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/users/email": {
|
||||
"put": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Update user email",
|
||||
"operationId": "update-user-email-experimental",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Update email request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UpdateUserEmailRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/users/{user}/skills": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -29703,6 +29739,23 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateUserEmailRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"new_email",
|
||||
"old_email"
|
||||
],
|
||||
"properties": {
|
||||
"new_email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"old_email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateUserNotificationPreferences": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+46
@@ -211,6 +211,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/users/email": {
|
||||
"put": {
|
||||
"consumes": ["application/json"],
|
||||
"tags": ["Users"],
|
||||
"summary": "Update user email",
|
||||
"operationId": "update-user-email-experimental",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Update email request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UpdateUserEmailRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/users/{user}/skills": {
|
||||
"get": {
|
||||
"produces": ["application/json"],
|
||||
@@ -27333,6 +27365,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateUserEmailRequest": {
|
||||
"type": "object",
|
||||
"required": ["new_email", "old_email"],
|
||||
"properties": {
|
||||
"new_email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"old_email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateUserNotificationPreferences": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1327,6 +1327,11 @@ func New(options *Options) *API {
|
||||
httpmw.ReportCLITelemetry(api.Logger, options.Telemetry),
|
||||
)
|
||||
|
||||
r.Route("/users/email", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
r.Put("/", api.putUserEmailExperimental)
|
||||
})
|
||||
|
||||
// NOTE(DanielleMaywood):
|
||||
r.Route("/users/{user}/skills", func(r chi.Router) {
|
||||
r.Use(
|
||||
|
||||
@@ -8326,6 +8326,22 @@ func (q *querier) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error
|
||||
return deleteQ(q.log, q.auth, q.db.GetUserByID, q.db.UpdateUserDeletedByID)(ctx, id)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateUserEmail(ctx context.Context, arg database.UpdateUserEmailParams) (database.User, error) {
|
||||
// Resolve the existing user by old email to obtain the RBAC object for
|
||||
// authorization. The handler enforces the built-in owner role gate; this
|
||||
// check adds defense-in-depth at the database layer.
|
||||
existing, err := q.db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
|
||||
Email: arg.OldEmail,
|
||||
})
|
||||
if err != nil {
|
||||
return database.User{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, existing); err != nil {
|
||||
return database.User{}, err
|
||||
}
|
||||
return q.db.UpdateUserEmail(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateUserGithubComUserID(ctx context.Context, arg database.UpdateUserGithubComUserIDParams) error {
|
||||
user, err := q.db.GetUserByID(ctx, arg.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -3159,6 +3159,13 @@ func (s *MethodTestSuite) TestUser() {
|
||||
dbm.EXPECT().UpdateUserDeletedByID(gomock.Any(), u.ID).Return(nil).AnyTimes()
|
||||
check.Args(u.ID).Asserts(u, policy.ActionDelete).Returns()
|
||||
}))
|
||||
s.Run("UpdateUserEmail", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
u := testutil.Fake(s.T(), faker, database.User{})
|
||||
arg := database.UpdateUserEmailParams{OldEmail: u.Email, NewEmail: "new@example.com", UpdatedAt: u.UpdatedAt}
|
||||
dbm.EXPECT().GetUserByEmailOrUsername(gomock.Any(), database.GetUserByEmailOrUsernameParams{Email: u.Email}).Return(u, nil).AnyTimes()
|
||||
dbm.EXPECT().UpdateUserEmail(gomock.Any(), arg).Return(u, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(u, policy.ActionUpdate).Returns(u)
|
||||
}))
|
||||
s.Run("UpdateUserGithubComUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
u := testutil.Fake(s.T(), faker, database.User{})
|
||||
arg := database.UpdateUserGithubComUserIDParams{ID: u.ID}
|
||||
|
||||
+8
@@ -5801,6 +5801,14 @@ func (m queryMetricsStore) UpdateUserDeletedByID(ctx context.Context, id uuid.UU
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateUserEmail(ctx context.Context, arg database.UpdateUserEmailParams) (database.User, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateUserEmail(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdateUserEmail").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserEmail").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateUserGithubComUserID(ctx context.Context, arg database.UpdateUserGithubComUserIDParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpdateUserGithubComUserID(ctx, arg)
|
||||
|
||||
Generated
+15
@@ -10962,6 +10962,21 @@ func (mr *MockStoreMockRecorder) UpdateUserDeletedByID(ctx, id any) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserDeletedByID", reflect.TypeOf((*MockStore)(nil).UpdateUserDeletedByID), ctx, id)
|
||||
}
|
||||
|
||||
// UpdateUserEmail mocks base method.
|
||||
func (m *MockStore) UpdateUserEmail(ctx context.Context, arg database.UpdateUserEmailParams) (database.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateUserEmail", ctx, arg)
|
||||
ret0, _ := ret[0].(database.User)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateUserEmail indicates an expected call of UpdateUserEmail.
|
||||
func (mr *MockStoreMockRecorder) UpdateUserEmail(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserEmail", reflect.TypeOf((*MockStore)(nil).UpdateUserEmail), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateUserGithubComUserID mocks base method.
|
||||
func (m *MockStore) UpdateUserGithubComUserID(ctx context.Context, arg database.UpdateUserGithubComUserIDParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+1
@@ -1587,6 +1587,7 @@ type sqlcQuerier interface {
|
||||
UpdateUserChatCustomPrompt(ctx context.Context, arg UpdateUserChatCustomPromptParams) (UserConfig, error)
|
||||
UpdateUserCodeDiffDisplayMode(ctx context.Context, arg UpdateUserCodeDiffDisplayModeParams) (string, error)
|
||||
UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error
|
||||
UpdateUserEmail(ctx context.Context, arg UpdateUserEmailParams) (User, error)
|
||||
UpdateUserGithubComUserID(ctx context.Context, arg UpdateUserGithubComUserIDParams) error
|
||||
UpdateUserHashedOneTimePasscode(ctx context.Context, arg UpdateUserHashedOneTimePasscodeParams) error
|
||||
UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error
|
||||
|
||||
@@ -19106,3 +19106,74 @@ func sessionFamilyCounts(t *testing.T, data json.RawMessage) map[codersdk.AppFam
|
||||
require.NoError(t, err)
|
||||
return counts
|
||||
}
|
||||
|
||||
func TestUpdateUserEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
|
||||
sqlDB := testSQLDB(t)
|
||||
err := migrations.Up(sqlDB)
|
||||
require.NoError(t, err)
|
||||
db := database.New(sqlDB)
|
||||
ctx := context.Background()
|
||||
|
||||
// SQL-specific: the WHERE clause matches old_email case-insensitively.
|
||||
t.Run("MatchesCaseInsensitively", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
origEmail := "CaseSensitive" + testutil.GetRandomName(t) + "@example.com"
|
||||
user := dbgen.User(t, db, database.User{Email: origEmail})
|
||||
updated, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{
|
||||
OldEmail: strings.ToLower(origEmail),
|
||||
NewEmail: "newcase" + testutil.GetRandomName(t) + "@example.com",
|
||||
UpdatedAt: dbtime.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.ID, updated.ID, "should match the same user case-insensitively")
|
||||
})
|
||||
|
||||
// SQL-specific: the WHERE clause excludes soft-deleted users.
|
||||
t.Run("DoesNotMatchDeletedUsers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
err := db.UpdateUserDeletedByID(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{
|
||||
OldEmail: user.Email,
|
||||
NewEmail: "shouldfail@example.com",
|
||||
UpdatedAt: dbtime.Now(),
|
||||
})
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
})
|
||||
|
||||
// SQL-specific: new_email is stored verbatim (no lower-casing on write).
|
||||
t.Run("PreservesNewEmailCasing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
mixedCase := "Mixed.Case." + testutil.GetRandomName(t) + "@Example.COM"
|
||||
updated, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{
|
||||
OldEmail: user.Email,
|
||||
NewEmail: mixedCase,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, mixedCase, updated.Email, "new email casing must be preserved exactly")
|
||||
})
|
||||
|
||||
// SQL-specific: the unique index name is users_email_lower_idx and the
|
||||
// constraint fires on a case-variant collision.
|
||||
t.Run("RejectsUniqueEmailCollision", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
user1 := dbgen.User(t, db, database.User{})
|
||||
user2 := dbgen.User(t, db, database.User{})
|
||||
_, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{
|
||||
OldEmail: user1.Email,
|
||||
NewEmail: strings.ToUpper(user2.Email),
|
||||
UpdatedAt: dbtime.Now(),
|
||||
})
|
||||
require.True(t, database.IsUniqueViolation(err, database.UniqueUsersEmailLowerIndex),
|
||||
"expected unique_violation on users_email_lower_idx, got: %v", err)
|
||||
})
|
||||
}
|
||||
|
||||
Generated
+48
@@ -31815,6 +31815,54 @@ func (q *sqlQuerier) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) er
|
||||
return err
|
||||
}
|
||||
|
||||
const updateUserEmail = `-- name: UpdateUserEmail :one
|
||||
UPDATE
|
||||
users
|
||||
SET
|
||||
email = $1,
|
||||
updated_at = $2,
|
||||
hashed_one_time_passcode = NULL,
|
||||
one_time_passcode_expires_at = NULL
|
||||
WHERE
|
||||
LOWER(email) = LOWER($3)
|
||||
AND deleted = false
|
||||
RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account, chat_spend_limit_micros
|
||||
`
|
||||
|
||||
type UpdateUserEmailParams struct {
|
||||
NewEmail string `db:"new_email" json:"new_email"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
OldEmail string `db:"old_email" json:"old_email"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateUserEmail(ctx context.Context, arg UpdateUserEmailParams) (User, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateUserEmail, arg.NewEmail, arg.UpdatedAt, arg.OldEmail)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Username,
|
||||
&i.HashedPassword,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Status,
|
||||
&i.RBACRoles,
|
||||
&i.LoginType,
|
||||
&i.AvatarURL,
|
||||
&i.Deleted,
|
||||
&i.LastSeenAt,
|
||||
&i.QuietHoursSchedule,
|
||||
&i.Name,
|
||||
&i.GithubComUserID,
|
||||
&i.HashedOneTimePasscode,
|
||||
&i.OneTimePasscodeExpiresAt,
|
||||
&i.IsSystem,
|
||||
&i.IsServiceAccount,
|
||||
&i.ChatSpendLimitMicros,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateUserGithubComUserID = `-- name: UpdateUserGithubComUserID :exec
|
||||
UPDATE
|
||||
users
|
||||
|
||||
@@ -396,6 +396,19 @@ SET
|
||||
WHERE
|
||||
id = $1;
|
||||
|
||||
-- name: UpdateUserEmail :one
|
||||
UPDATE
|
||||
users
|
||||
SET
|
||||
email = @new_email,
|
||||
updated_at = @updated_at,
|
||||
hashed_one_time_passcode = NULL,
|
||||
one_time_passcode_expires_at = NULL
|
||||
WHERE
|
||||
LOWER(email) = LOWER(@old_email)
|
||||
AND deleted = false
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateUserDeletedByID :exec
|
||||
UPDATE
|
||||
users
|
||||
|
||||
+152
@@ -7,8 +7,10 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
@@ -866,6 +868,156 @@ func (*API) userLoginType(rw http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
type updateUserEmailAuditFields struct {
|
||||
OldEmail string `json:"old_email,omitempty"`
|
||||
NewEmail string `json:"new_email,omitempty"`
|
||||
}
|
||||
|
||||
// putUserEmailExperimental updates a user's email address and revokes all of
|
||||
// their Coder credentials.
|
||||
//
|
||||
// @Summary Update user email
|
||||
// @ID update-user-email-experimental
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Tags Users
|
||||
// @Param request body codersdk.UpdateUserEmailRequest true "Update email request"
|
||||
// @Success 204
|
||||
// @Router /api/experimental/users/email [put]
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) putUserEmailExperimental(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
apiKey = httpmw.APIKey(r)
|
||||
auditor = *api.Auditor.Load()
|
||||
auditFields = &updateUserEmailAuditFields{}
|
||||
aReq, done = audit.InitRequest[database.User](rw, &audit.RequestParams{
|
||||
Audit: auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionWrite,
|
||||
AdditionalFields: auditFields,
|
||||
})
|
||||
)
|
||||
defer done()
|
||||
|
||||
actor, err := api.Database.GetUserByID(ctx, apiKey.UserID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get acting user.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
aReq.Old = actor
|
||||
|
||||
if !slices.Contains(actor.RBACRoles, rbac.RoleOwner().String()) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
var rawReq struct {
|
||||
OldEmail string `json:"old_email"`
|
||||
NewEmail string `json:"new_email"`
|
||||
}
|
||||
if !httpapi.Read(ctx, rw, r, &rawReq) {
|
||||
return
|
||||
}
|
||||
auditFields.OldEmail = rawReq.OldEmail
|
||||
auditFields.NewEmail = rawReq.NewEmail
|
||||
|
||||
req := codersdk.UpdateUserEmailRequest{
|
||||
OldEmail: rawReq.OldEmail,
|
||||
NewEmail: rawReq.NewEmail,
|
||||
}
|
||||
if err := httpapi.Validate.Struct(req); err != nil {
|
||||
var validationErrors validator.ValidationErrors
|
||||
if errors.As(err, &validationErrors) {
|
||||
apiErrors := make([]codersdk.ValidationError, 0, len(validationErrors))
|
||||
for _, validationError := range validationErrors {
|
||||
apiErrors = append(apiErrors, codersdk.ValidationError{
|
||||
Field: validationError.Field(),
|
||||
Detail: fmt.Sprintf("Validation failed for tag %q with value: \"%v\"", validationError.Tag(), validationError.Value()),
|
||||
})
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Validation failed.",
|
||||
Validations: apiErrors,
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error validating request body payload.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var updated database.User
|
||||
err = api.Database.InTx(func(tx database.Store) error {
|
||||
target, err := tx.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
|
||||
Email: req.OldEmail,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aReq.Old = target
|
||||
|
||||
if target.ID == actor.ID {
|
||||
return errUpdateUserEmailSelf
|
||||
}
|
||||
if strings.EqualFold(req.OldEmail, req.NewEmail) {
|
||||
return errUpdateUserEmailUnchanged
|
||||
}
|
||||
|
||||
updated, err = tx.UpdateUserEmail(ctx, database.UpdateUserEmailParams{
|
||||
OldEmail: req.OldEmail,
|
||||
NewEmail: req.NewEmail,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:gocritic // This break-glass operation must revoke all API keys
|
||||
// owned by the target user, not only keys visible to the acting owner.
|
||||
return tx.DeleteAPIKeysByUserID(dbauthz.AsAPIKeyRevoker(ctx, target.ID), target.ID)
|
||||
}, nil)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
httpapi.ResourceNotFound(rw)
|
||||
case errors.Is(err, errUpdateUserEmailSelf):
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "You cannot update your own email address.",
|
||||
})
|
||||
case errors.Is(err, errUpdateUserEmailUnchanged):
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "The old and new email addresses must differ beyond letter casing.",
|
||||
})
|
||||
case database.IsUniqueViolation(err, database.UniqueIndexUsersEmail),
|
||||
database.IsUniqueViolation(err, database.UniqueUsersEmailLowerIndex):
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
Message: "A user with the new email address already exists.",
|
||||
})
|
||||
default:
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to update user email.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
aReq.New = updated
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
var (
|
||||
errUpdateUserEmailSelf = xerrors.New("cannot update own email")
|
||||
errUpdateUserEmailUnchanged = xerrors.New("old and new email are equal")
|
||||
)
|
||||
|
||||
// @Summary Update user profile
|
||||
// @ID update-user-profile
|
||||
// @Security CoderSessionToken
|
||||
|
||||
@@ -2,6 +2,8 @@ package coderd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
@@ -1212,6 +1214,164 @@ func TestNotifyCreatedUser(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateUserEmailExperimental(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Share one server because these assertions do not require isolated state.
|
||||
auditor := audit.NewMock()
|
||||
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Auditor: auditor})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Only built-in deployment owners may use the endpoint.
|
||||
userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleUserAdmin())
|
||||
_, authTarget := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
auditor.ResetLogs()
|
||||
|
||||
err := userAdminClient.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{
|
||||
OldEmail: authTarget.Email,
|
||||
NewEmail: fmt.Sprintf("new-%s@example.com", uuid.NewString()),
|
||||
})
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
unchanged, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), authTarget.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, authTarget.Email, unchanged.Email)
|
||||
logs := auditor.AuditLogs()
|
||||
require.Len(t, logs, 1)
|
||||
require.Equal(t, int32(http.StatusNotFound), logs[0].StatusCode)
|
||||
require.JSONEq(t, `{}`, string(logs[0].AdditionalFields))
|
||||
|
||||
// Owners cannot update their own email.
|
||||
ownerUser, err := client.User(ctx, owner.UserID.String())
|
||||
require.NoError(t, err)
|
||||
err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{
|
||||
OldEmail: ownerUser.Email,
|
||||
NewEmail: fmt.Sprintf("self-%s@example.com", uuid.NewString()),
|
||||
})
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
|
||||
// Invalid inputs do not change user state.
|
||||
_, validTarget := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
for _, req := range []codersdk.UpdateUserEmailRequest{
|
||||
{OldEmail: " " + validTarget.Email, NewEmail: "new@example.com"},
|
||||
{OldEmail: validTarget.Email, NewEmail: validTarget.Email},
|
||||
{OldEmail: strings.ToUpper(validTarget.Email), NewEmail: validTarget.Email},
|
||||
} {
|
||||
err := client.UpdateUserEmail(ctx, req)
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
}
|
||||
|
||||
// The old email must exist and the new email must be available.
|
||||
_, conflictTarget := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
_, existingUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{
|
||||
OldEmail: fmt.Sprintf("missing-%s@example.com", uuid.NewString()),
|
||||
NewEmail: fmt.Sprintf("new-%s@example.com", uuid.NewString()),
|
||||
})
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
|
||||
err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{
|
||||
OldEmail: conflictTarget.Email,
|
||||
NewEmail: strings.ToUpper(existingUser.Email),
|
||||
})
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
|
||||
|
||||
// Login type, status, and the target's owner role do not prevent updates.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
loginType database.LoginType
|
||||
role rbac.RoleIdentifier
|
||||
status database.UserStatus
|
||||
}{
|
||||
{name: "OIDC", loginType: database.LoginTypeOIDC},
|
||||
{name: "GitHub", loginType: database.LoginTypeGithub},
|
||||
{name: "Dormant", status: database.UserStatusDormant},
|
||||
{name: "Suspended", status: database.UserStatusSuspended},
|
||||
{name: "Owner", role: rbac.RoleOwner()},
|
||||
} {
|
||||
roles := []rbac.RoleIdentifier{}
|
||||
if tc.role.Name != "" {
|
||||
roles = append(roles, tc.role)
|
||||
}
|
||||
_, variant := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, roles...)
|
||||
if tc.loginType != "" {
|
||||
variantUpdated, err := db.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{
|
||||
UserID: variant.ID,
|
||||
NewLoginType: tc.loginType,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
variant.LoginType = codersdk.LoginType(variantUpdated.LoginType)
|
||||
}
|
||||
if tc.status != "" {
|
||||
_, err := db.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{
|
||||
ID: variant.ID,
|
||||
Status: tc.status,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
UserIsSeen: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
newVariantEmail := fmt.Sprintf("variant-%s@example.com", uuid.NewString())
|
||||
err := client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{
|
||||
OldEmail: variant.Email,
|
||||
NewEmail: newVariantEmail,
|
||||
})
|
||||
require.NoError(t, err, "variant %s should succeed", tc.name)
|
||||
}
|
||||
|
||||
// The API clears OTP state, revokes tokens, and records the audit event.
|
||||
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
oldEmail := member.Email
|
||||
newEmail := fmt.Sprintf("Updated-%s@example.com", uuid.NewString())
|
||||
oldToken := memberClient.SessionToken()
|
||||
extraToken, err := client.CreateToken(ctx, member.ID.String(), codersdk.CreateTokenRequest{})
|
||||
require.NoError(t, err)
|
||||
err = db.UpdateUserHashedOneTimePasscode(dbauthz.AsSystemRestricted(ctx), database.UpdateUserHashedOneTimePasscodeParams{
|
||||
ID: member.ID,
|
||||
HashedOneTimePasscode: []byte("hashed-passcode"),
|
||||
OneTimePasscodeExpiresAt: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
auditor.ResetLogs()
|
||||
|
||||
err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{
|
||||
OldEmail: oldEmail,
|
||||
NewEmail: newEmail,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
sideEffectsUser, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), member.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, sideEffectsUser.HashedOneTimePasscode)
|
||||
require.False(t, sideEffectsUser.OneTimePasscodeExpiresAt.Valid)
|
||||
|
||||
for _, token := range []string{oldToken, extraToken.Key} {
|
||||
revokedClient := codersdk.New(client.URL, codersdk.WithSessionToken(token))
|
||||
_, err := revokedClient.User(ctx, codersdk.Me)
|
||||
require.Error(t, err)
|
||||
}
|
||||
_, err = client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
|
||||
logs = auditor.AuditLogs()
|
||||
require.Len(t, logs, 1)
|
||||
require.Equal(t, owner.UserID, logs[0].UserID)
|
||||
require.Equal(t, member.ID, logs[0].ResourceID)
|
||||
require.Equal(t, database.AuditActionWrite, logs[0].Action)
|
||||
require.Equal(t, int32(http.StatusNoContent), logs[0].StatusCode)
|
||||
var fields map[string]string
|
||||
require.NoError(t, json.Unmarshal(logs[0].AdditionalFields, &fields))
|
||||
require.Equal(t, oldEmail, fields["old_email"])
|
||||
require.Equal(t, newEmail, fields["new_email"])
|
||||
}
|
||||
|
||||
func TestUpdateUserProfile(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("UserNotFound", func(t *testing.T) {
|
||||
|
||||
@@ -238,6 +238,13 @@ type UpdateUserProfileRequest struct {
|
||||
AvatarURL string `json:"avatar_url" format:"uri"`
|
||||
}
|
||||
|
||||
// UpdateUserEmailRequest changes a user's email by matching their current
|
||||
// email address. This API is experimental and may change without notice.
|
||||
type UpdateUserEmailRequest struct {
|
||||
OldEmail string `json:"old_email" validate:"required,email" format:"email"`
|
||||
NewEmail string `json:"new_email" validate:"required,email" format:"email"`
|
||||
}
|
||||
|
||||
type ValidateUserPasswordRequest struct {
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
@@ -608,6 +615,20 @@ func (c *Client) UpdateUserProfile(ctx context.Context, user string, req UpdateU
|
||||
return resp, ReadBodyAsJSON(res, &resp)
|
||||
}
|
||||
|
||||
// UpdateUserEmail changes a user's email address. This API is experimental and
|
||||
// may change without notice.
|
||||
func (c *Client) UpdateUserEmail(ctx context.Context, req UpdateUserEmailRequest) error {
|
||||
res, err := c.Request(ctx, http.MethodPut, "/api/experimental/users/email", req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateUserPassword validates the complexity of a user password and that it is secured enough.
|
||||
func (c *Client) ValidateUserPassword(ctx context.Context, req ValidateUserPasswordRequest) (ValidateUserPasswordResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/validate-password", req)
|
||||
|
||||
Generated
+16
@@ -15876,6 +15876,22 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
|
||||
| `model_config_id` | string | false | | |
|
||||
| `reasoning_effort` | string | false | | |
|
||||
|
||||
## codersdk.UpdateUserEmailRequest
|
||||
|
||||
```json
|
||||
{
|
||||
"new_email": "user@example.com",
|
||||
"old_email": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|-------------|--------|----------|--------------|-------------|
|
||||
| `new_email` | string | true | | |
|
||||
| `old_email` | string | true | | |
|
||||
|
||||
## codersdk.UpdateUserNotificationPreferences
|
||||
|
||||
```json
|
||||
|
||||
Generated
+10
@@ -10032,6 +10032,16 @@ export interface UpdateUserChatPersonalModelOverrideRequest {
|
||||
readonly reasoning_effort?: string;
|
||||
}
|
||||
|
||||
// From codersdk/users.go
|
||||
/**
|
||||
* UpdateUserEmailRequest changes a user's email by matching their current
|
||||
* email address. This API is experimental and may change without notice.
|
||||
*/
|
||||
export interface UpdateUserEmailRequest {
|
||||
readonly old_email: string;
|
||||
readonly new_email: string;
|
||||
}
|
||||
|
||||
// From codersdk/notifications.go
|
||||
export interface UpdateUserNotificationPreferences {
|
||||
readonly template_disabled_map: Record<string, boolean>;
|
||||
|
||||
Reference in New Issue
Block a user