Files
coder/codersdk/users.go
T
McKayla はな efd93027ce feat: allow editing user avatars (#26652)
Adds an avatar URL field to the admin **Edit user** page, available only
for users whose login type is `password` or `none`.

For identity-provider login types (`github`, `oidc`) the avatar is
synced from the IdP on every login, so the field is hidden and the API
ignores any submitted avatar to avoid confusing overwrites.

The field reuses the same emoji picker + URL input (`IconField`) already
used for template, group, and organization icons.

A follow-up PR will add the same control to the self-service Account
settings page.

<details>
<summary>Implementation plan & decisions</summary>

**Goal:** Let an admin set/clear a user's avatar from the Edit user
page, gated to `password`/`none` login types.

**Backend**
- Add `avatar_url` to `codersdk.UpdateUserProfileRequest`.
- `putUserProfile` applies the submitted avatar only for
`password`/`none`; otherwise it preserves the existing (IdP-synced)
value.
- Regenerated TS types and API docs via `make gen`.

**Frontend**
- `EditUserForm` renders an `IconField` ("Avatar URL") when the login
type allows it.
- `EditUserPage` passes the avatar value and a `canEditAvatar` flag.
- `AccountPage` round-trips `avatar_url` so the shared request type
doesn't wipe avatars on the self-service path.

**Gating** is enforced in both the UI (field hidden) and the backend
(submitted value ignored for IdP login types).

**Tests/stories:** backend `TestUpdateUserProfile` covers apply
(password) and ignore (SSO); `EditUserForm` stories cover the
shown/hidden states with interaction tests.

</details>

---
> Generated by Coder Agents on behalf of @aslilac.
2026-06-26 16:35:03 -06:00

1094 lines
40 KiB
Go

package codersdk
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
)
// Me is used as a replacement for your own ID.
var Me = "me"
type UserStatus string
const (
UserStatusActive UserStatus = "active"
UserStatusDormant UserStatus = "dormant"
UserStatusSuspended UserStatus = "suspended"
)
type UsersRequest struct {
Search string `json:"search,omitempty" typescript:"-"`
Name string `json:"name,omitempty" typescript:"-"`
// Filter users by status.
Status UserStatus `json:"status,omitempty" typescript:"-"`
// Filter users that have the given role.
Role string `json:"role,omitempty" typescript:"-"`
LoginType []LoginType `json:"login_type,omitempty" typescript:"-"`
SearchQuery string `json:"q,omitempty"`
Pagination
}
func (req UsersRequest) asRequestOption() RequestOption {
return func(r *http.Request) {
q := r.URL.Query()
var params []string
if req.Search != "" {
params = append(params, req.Search)
}
if req.Name != "" {
params = append(params, "name:"+req.Name)
}
if req.Status != "" {
params = append(params, "status:"+string(req.Status))
}
if req.Role != "" {
params = append(params, "role:"+req.Role)
}
if req.SearchQuery != "" {
params = append(params, req.SearchQuery)
}
for _, lt := range req.LoginType {
params = append(params, "login_type:"+string(lt))
}
q.Set("q", strings.Join(params, " "))
r.URL.RawQuery = q.Encode()
}
}
// MinimalUser is the minimal information needed to identify a user and show
// them on the UI.
type MinimalUser struct {
ID uuid.UUID `json:"id" validate:"required" table:"id" format:"uuid"`
Username string `json:"username" validate:"required" table:"username,default_sort"`
Name string `json:"name,omitempty" table:"name"`
AvatarURL string `json:"avatar_url,omitempty" format:"uri"`
}
// ReducedUser omits role and organization information. Roles are deduced from
// the user's site and organization roles. This requires fetching the user's
// organizational memberships. Fetching that is more expensive, and not usually
// required by the frontend.
type ReducedUser struct {
MinimalUser `table:"m,recursive_inline"`
Email string `json:"email" validate:"required" table:"email" format:"email"`
CreatedAt time.Time `json:"created_at" validate:"required" table:"created at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" table:"updated at" format:"date-time"`
LastSeenAt time.Time `json:"last_seen_at,omitempty" format:"date-time"`
Status UserStatus `json:"status" table:"status" enums:"active,suspended"`
LoginType LoginType `json:"login_type"`
IsServiceAccount bool `json:"is_service_account,omitempty"`
// Deprecated: this value should be retrieved from
// `codersdk.UserPreferenceSettings` instead.
ThemePreference string `json:"theme_preference,omitempty"`
}
// User represents a user in Coder.
type User struct {
ReducedUser `table:"r,recursive_inline"`
OrganizationIDs []uuid.UUID `json:"organization_ids" format:"uuid"`
Roles []SlimRole `json:"roles"`
// HasAISeat intentionally omits omitempty so the API always includes the
// field, even when false.
HasAISeat bool `json:"has_ai_seat"`
}
type GetUsersResponse struct {
Users []User `json:"users"`
Count int `json:"count"`
}
// @typescript-ignore LicensorTrialRequest
type LicensorTrialRequest struct {
DeploymentID string `json:"deployment_id"`
Email string `json:"email"`
Source string `json:"source"`
// Personal details.
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
PhoneNumber string `json:"phone_number"`
JobTitle string `json:"job_title"`
CompanyName string `json:"company_name"`
Country string `json:"country"`
Developers string `json:"developers"`
}
type CreateFirstUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Name string `json:"name" validate:"user_real_name"`
Password string `json:"password" validate:"required"`
Trial bool `json:"trial"`
TrialInfo CreateFirstUserTrialInfo `json:"trial_info"`
OnboardingInfo *CreateFirstUserOnboardingInfo `json:"onboarding_info,omitempty"`
}
type CreateFirstUserTrialInfo struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
PhoneNumber string `json:"phone_number"`
JobTitle string `json:"job_title"`
CompanyName string `json:"company_name"`
Country string `json:"country"`
Developers string `json:"developers"`
}
// CreateFirstUserOnboardingInfo contains optional newsletter preference
// data collected during first user setup.
type CreateFirstUserOnboardingInfo struct {
NewsletterMarketing bool `json:"newsletter_marketing"`
NewsletterReleases bool `json:"newsletter_releases"`
}
// CreateFirstUserResponse contains IDs for newly created user info.
type CreateFirstUserResponse struct {
UserID uuid.UUID `json:"user_id" format:"uuid"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
}
// CreateUserRequest
// Deprecated: Use CreateUserRequestWithOrgs instead. This will be removed.
// TODO: When removing, we should rename CreateUserRequestWithOrgs -> CreateUserRequest
// Then alias CreateUserRequestWithOrgs to CreateUserRequest.
// @typescript-ignore CreateUserRequest
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email" format:"email"`
Username string `json:"username" validate:"required,username"`
Name string `json:"name" validate:"user_real_name"`
Password string `json:"password"`
// UserLoginType defaults to LoginTypePassword.
UserLoginType LoginType `json:"login_type"`
// DisableLogin sets the user's login type to 'none'. This prevents the user
// from being able to use a password or any other authentication method to login.
// Deprecated: Set UserLoginType=LoginTypeDisabled instead.
DisableLogin bool `json:"disable_login"`
OrganizationID uuid.UUID `json:"organization_id" validate:"" format:"uuid"`
}
type CreateUserRequestWithOrgs struct {
Email string `json:"email" validate:"required_unless=ServiceAccount true,omitempty,email" format:"email"`
Username string `json:"username" validate:"required,username"`
Name string `json:"name" validate:"user_real_name"`
Password string `json:"password"`
// UserLoginType defaults to LoginTypePassword.
UserLoginType LoginType `json:"login_type"`
// UserStatus defaults to UserStatusDormant.
UserStatus *UserStatus `json:"user_status"`
// OrganizationIDs is a list of organization IDs that the user should be a member of.
OrganizationIDs []uuid.UUID `json:"organization_ids" validate:"" format:"uuid"`
// Service accounts are admin-managed accounts that cannot login.
ServiceAccount bool `json:"service_account,omitempty"`
// Roles is an optional list of site-level roles to assign at creation.
Roles []string `json:"roles,omitempty"`
}
// UnmarshalJSON implements the unmarshal for the legacy param "organization_id".
// To accommodate multiple organizations, the field has been switched to a slice.
// The previous field will just be appended to the slice.
// Note in the previous behavior, omitting the field would result in the
// default org being applied, but that is no longer the case.
// TODO: Remove this method in it's entirety after some period of time.
// This will be released in v1.16.0, and is associated with the multiple orgs
// feature.
func (r *CreateUserRequestWithOrgs) UnmarshalJSON(data []byte) error {
// By using a type alias, we prevent an infinite recursion when unmarshalling.
// This allows us to use the default unmarshal behavior of the original type.
type AliasedReq CreateUserRequestWithOrgs
type DeprecatedCreateUserRequest struct {
AliasedReq
OrganizationID *uuid.UUID `json:"organization_id" format:"uuid"`
}
var dep DeprecatedCreateUserRequest
err := json.Unmarshal(data, &dep)
if err != nil {
return err
}
*r = CreateUserRequestWithOrgs(dep.AliasedReq)
if dep.OrganizationID != nil {
r.OrganizationIDs = append(r.OrganizationIDs, *dep.OrganizationID)
}
return nil
}
type UpdateUserProfileRequest struct {
Username string `json:"username" validate:"required,username"`
Name string `json:"name" validate:"user_real_name"`
// AvatarURL is only applied for users whose login type is password or
// none. For other login types the avatar is synced from the identity
// provider on login, so a submitted value is ignored.
AvatarURL string `json:"avatar_url" format:"uri"`
}
type ValidateUserPasswordRequest struct {
Password string `json:"password" validate:"required"`
}
type ValidateUserPasswordResponse struct {
Valid bool `json:"valid"`
Details string `json:"details"`
}
// TerminalFontName is the name of supported terminal font
type TerminalFontName string
var TerminalFontNames = []TerminalFontName{
TerminalFontUnknown, TerminalFontGeistMono, TerminalFontIBMPlexMono,
TerminalFontFiraCode, TerminalFontSourceCodePro, TerminalFontJetBrainsMono,
}
const (
TerminalFontUnknown TerminalFontName = ""
TerminalFontGeistMono TerminalFontName = "geist-mono"
TerminalFontIBMPlexMono TerminalFontName = "ibm-plex-mono"
TerminalFontFiraCode TerminalFontName = "fira-code"
TerminalFontSourceCodePro TerminalFontName = "source-code-pro"
TerminalFontJetBrainsMono TerminalFontName = "jetbrains-mono"
)
type ThemeMode string
const (
// ThemeModeUnset is the server-side default when the user has never
// set a theme_mode. It is also stored for legacy auto preferences so
// clients can migrate the old sync-with-system setting. Clients should
// inspect ThemePreference for legacy auto values before treating unset
// mode as ThemeModeSingle for backward compatibility with PR #24672.
ThemeModeUnset ThemeMode = ""
ThemeModeSync ThemeMode = "sync"
ThemeModeSingle ThemeMode = "single"
)
type UserAppearanceSettings struct {
// ThemePreference is the legacy single-field appearance setting. In
// "single" mode it mirrors the active theme. In "sync" mode modern
// clients normally mirror the active OS slot, but older clients can
// update only this field, so it may diverge from ThemeLight or
// ThemeDark until a modern client saves the full appearance state
// again.
ThemePreference string `json:"theme_preference"`
ThemeMode ThemeMode `json:"theme_mode"`
// Ignored when ThemeMode is "single"
ThemeLight string `json:"theme_light"`
// Ignored when ThemeMode is "single"
ThemeDark string `json:"theme_dark"`
TerminalFont TerminalFontName `json:"terminal_font"`
}
type UpdateUserAppearanceSettingsRequest struct {
ThemePreference string `json:"theme_preference" validate:"required"`
// ThemeMode is optional for backward compatibility. When empty,
// the server leaves theme_mode, theme_light, and theme_dark
// unchanged so older CLI clients do not erase sync-mode settings.
// Legacy auto preferences are the exception: they clear theme_mode
// so clients can migrate the old sync-with-system setting.
ThemeMode ThemeMode `json:"theme_mode" validate:"omitempty,oneof=sync single"`
// ThemeLight is required when ThemeMode is "sync". In "single"
// mode an empty value means "preserve the previously persisted
// slot" rather than "clear the slot", so partial updates that send
// only one slot keep the other intact.
ThemeLight string `json:"theme_light" validate:"required_if=ThemeMode sync,omitempty,oneof=light light-protan-deuter light-tritan dark dark-protan-deuter dark-tritan"`
// ThemeDark is required when ThemeMode is "sync". In "single" mode
// an empty value means "preserve the previously persisted slot"
// rather than "clear the slot", so partial updates that send only
// one slot keep the other intact.
ThemeDark string `json:"theme_dark" validate:"required_if=ThemeMode sync,omitempty,oneof=light light-protan-deuter light-tritan dark dark-protan-deuter dark-tritan"`
TerminalFont TerminalFontName `json:"terminal_font" validate:"required"`
}
type UserPreferenceSettings struct {
TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"`
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode"`
ShellToolDisplayMode AgentDisplayMode `json:"shell_tool_display_mode"`
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode"`
AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut"`
}
type UpdateUserPreferenceSettingsRequest struct {
TaskNotificationAlertDismissed *bool `json:"task_notification_alert_dismissed,omitempty"`
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode,omitempty"`
ShellToolDisplayMode AgentDisplayMode `json:"shell_tool_display_mode,omitempty"`
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode,omitempty"`
AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut,omitempty"`
}
type AgentChatSendShortcut string
const (
AgentChatSendShortcutEnter AgentChatSendShortcut = "enter"
AgentChatSendShortcutModifierEnter AgentChatSendShortcut = "modifier_enter"
)
var ValidAgentChatSendShortcuts = []AgentChatSendShortcut{
AgentChatSendShortcutEnter,
AgentChatSendShortcutModifierEnter,
}
type ThinkingDisplayMode string
const (
ThinkingDisplayModeAuto ThinkingDisplayMode = "auto"
ThinkingDisplayModePreview ThinkingDisplayMode = "preview"
ThinkingDisplayModeAlwaysExpanded ThinkingDisplayMode = "always_expanded"
ThinkingDisplayModeAlwaysCollapsed ThinkingDisplayMode = "always_collapsed"
)
var ValidThinkingDisplayModes = []ThinkingDisplayMode{
ThinkingDisplayModeAuto,
ThinkingDisplayModePreview,
ThinkingDisplayModeAlwaysExpanded,
ThinkingDisplayModeAlwaysCollapsed,
}
type AgentDisplayMode string
const (
AgentDisplayModeAuto AgentDisplayMode = "auto"
AgentDisplayModeAlwaysExpanded AgentDisplayMode = "always_expanded"
AgentDisplayModeAlwaysCollapsed AgentDisplayMode = "always_collapsed"
)
var ValidAgentDisplayModes = []AgentDisplayMode{
AgentDisplayModeAuto,
AgentDisplayModeAlwaysExpanded,
AgentDisplayModeAlwaysCollapsed,
}
type UpdateUserPasswordRequest struct {
OldPassword string `json:"old_password" validate:""`
Password string `json:"password" validate:"required"`
}
type UserQuietHoursScheduleResponse struct {
RawSchedule string `json:"raw_schedule"`
// UserSet is true if the user has set their own quiet hours schedule. If
// false, the user is using the default schedule.
UserSet bool `json:"user_set"`
// UserCanSet is true if the user is allowed to set their own quiet hours
// schedule. If false, the user cannot set a custom schedule and the default
// schedule will always be used.
UserCanSet bool `json:"user_can_set"`
// Time is the time of day that the quiet hours window starts in the given
// Timezone each day.
Time string `json:"time"` // HH:mm (24-hour)
Timezone string `json:"timezone"` // raw format from the cron expression, UTC if unspecified
// Next is the next time that the quiet hours window will start.
Next time.Time `json:"next" format:"date-time"`
}
type UpdateUserQuietHoursScheduleRequest struct {
// Schedule is a cron expression that defines when the user's quiet hours
// window is. Schedule must not be empty. For new users, the schedule is set
// to 2am in their browser or computer's timezone. The schedule denotes the
// beginning of a 4 hour window where the workspace is allowed to
// automatically stop or restart due to maintenance or template schedule.
//
// The schedule must be daily with a single time, and should have a timezone
// specified via a CRON_TZ prefix (otherwise UTC will be used).
//
// If the schedule is empty, the user will be updated to use the default
// schedule.
Schedule string `json:"schedule" validate:"required"`
}
type UpdateRoles struct {
Roles []string `json:"roles" validate:""`
}
type UserRoles struct {
Roles []string `json:"roles"`
OrganizationRoles map[uuid.UUID][]string `json:"organization_roles"`
}
type ConvertLoginRequest struct {
// ToType is the login type to convert to.
ToType LoginType `json:"to_type" validate:"required"`
Password string `json:"password" validate:"required"`
}
// LoginWithPasswordRequest enables callers to authenticate with email and password.
type LoginWithPasswordRequest struct {
Email string `json:"email" validate:"required,email" format:"email"`
Password string `json:"password" validate:"required"`
}
// LoginWithPasswordResponse contains a session token for the newly authenticated user.
type LoginWithPasswordResponse struct {
SessionToken string `json:"session_token" validate:"required"`
}
// RequestOneTimePasscodeRequest enables callers to request a one-time-passcode to change their password.
type RequestOneTimePasscodeRequest struct {
Email string `json:"email" validate:"required,email" format:"email"`
}
// ChangePasswordWithOneTimePasscodeRequest enables callers to change their password when they've forgotten it.
type ChangePasswordWithOneTimePasscodeRequest struct {
Email string `json:"email" validate:"required,email" format:"email"`
Password string `json:"password" validate:"required"`
OneTimePasscode string `json:"one_time_passcode" validate:"required"`
}
type OAuthConversionResponse struct {
StateString string `json:"state_string"`
ExpiresAt time.Time `json:"expires_at" format:"date-time"`
ToType LoginType `json:"to_type"`
UserID uuid.UUID `json:"user_id" format:"uuid"`
}
// AuthMethods contains authentication method information like whether they are enabled or not or custom text, etc.
type AuthMethods struct {
TermsOfServiceURL string `json:"terms_of_service_url,omitempty"`
Password AuthMethod `json:"password"`
Github GithubAuthMethod `json:"github"`
OIDC OIDCAuthMethod `json:"oidc"`
}
type AuthMethod struct {
Enabled bool `json:"enabled"`
}
type UserLoginType struct {
LoginType LoginType `json:"login_type"`
}
type GithubAuthMethod struct {
Enabled bool `json:"enabled"`
DefaultProviderConfigured bool `json:"default_provider_configured"`
}
type OIDCAuthMethod struct {
AuthMethod
SignInText string `json:"signInText"`
IconURL string `json:"iconUrl"`
}
// OIDCClaimsResponse represents the merged OIDC claims for a user.
type OIDCClaimsResponse struct {
// Claims are the merged claims from the OIDC provider. These
// are the union of the ID token claims and the userinfo claims,
// where userinfo claims take precedence on conflict.
Claims map[string]interface{} `json:"claims"`
}
type UserParameter struct {
Name string `json:"name"`
Value string `json:"value"`
}
// UserAutofillParameters returns all recently used parameters for the given user.
func (c *Client) UserAutofillParameters(ctx context.Context, user string, templateID uuid.UUID) ([]UserParameter, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/autofill-parameters?template_id=%s", user, templateID), nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var params []UserParameter
return params, json.NewDecoder(res.Body).Decode(&params)
}
// HasFirstUser returns whether the first user has been created.
func (c *Client) HasFirstUser(ctx context.Context) (bool, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/first", nil)
if err != nil {
return false, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotFound {
// ensure we are talking to coder and not
// some other service that returns 404
v := res.Header.Get(BuildVersionHeader)
if v == "" {
return false, xerrors.Errorf("missing build version header, not a coder instance")
}
return false, nil
}
if res.StatusCode != http.StatusOK {
return false, ReadBodyAsError(res)
}
return true, nil
}
// CreateFirstUser attempts to create the first user on a Coder deployment.
// This initial user has superadmin privileges. If >0 users exist, this request will fail.
func (c *Client) CreateFirstUser(ctx context.Context, req CreateFirstUserRequest) (CreateFirstUserResponse, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/first", req)
if err != nil {
return CreateFirstUserResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return CreateFirstUserResponse{}, ReadBodyAsError(res)
}
var resp CreateFirstUserResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// CreateUser
// Deprecated: Use CreateUserWithOrgs instead. This will be removed.
// TODO: When removing, we should rename CreateUserWithOrgs -> CreateUser
// with an alias of CreateUserWithOrgs.
func (c *Client) CreateUser(ctx context.Context, req CreateUserRequest) (User, error) {
if req.DisableLogin {
req.UserLoginType = LoginTypeNone
}
return c.CreateUserWithOrgs(ctx, CreateUserRequestWithOrgs{
Email: req.Email,
Username: req.Username,
Name: req.Name,
Password: req.Password,
UserLoginType: req.UserLoginType,
OrganizationIDs: []uuid.UUID{req.OrganizationID},
})
}
// CreateUserWithOrgs creates a new user.
func (c *Client) CreateUserWithOrgs(ctx context.Context, req CreateUserRequestWithOrgs) (User, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/users", req)
if err != nil {
return User{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return User{}, ReadBodyAsError(res)
}
var user User
return user, json.NewDecoder(res.Body).Decode(&user)
}
// DeleteUser deletes a user.
func (c *Client) DeleteUser(ctx context.Context, id uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s", id), nil)
if err != nil {
return err
}
defer res.Body.Close()
// Check for a 200 or a 204 response. 2.14.0 accidentally included a 204 response,
// which was a breaking change, and reverted in 2.14.1.
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// UpdateUserProfile updates the username of a user.
func (c *Client) UpdateUserProfile(ctx context.Context, user string, req UpdateUserProfileRequest) (User, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/profile", user), req)
if err != nil {
return User{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return User{}, ReadBodyAsError(res)
}
var resp User
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// 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)
if err != nil {
return ValidateUserPasswordResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ValidateUserPasswordResponse{}, ReadBodyAsError(res)
}
var resp ValidateUserPasswordResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateUserStatus sets the user status to the given status
func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserStatus) (User, error) {
path := fmt.Sprintf("/api/v2/users/%s/status/", user)
switch status {
case UserStatusActive:
path += "activate"
case UserStatusSuspended:
path += "suspend"
default:
return User{}, xerrors.Errorf("status %q is not supported", status)
}
res, err := c.Request(ctx, http.MethodPut, path, nil)
if err != nil {
return User{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return User{}, ReadBodyAsError(res)
}
var resp User
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// GetUserAppearanceSettings fetches the appearance settings for a user.
func (c *Client) GetUserAppearanceSettings(ctx context.Context, user string) (UserAppearanceSettings, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/appearance", user), nil)
if err != nil {
return UserAppearanceSettings{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserAppearanceSettings{}, ReadBodyAsError(res)
}
var resp UserAppearanceSettings
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateUserAppearanceSettings updates the appearance settings for a user.
func (c *Client) UpdateUserAppearanceSettings(ctx context.Context, user string, req UpdateUserAppearanceSettingsRequest) (UserAppearanceSettings, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/appearance", user), req)
if err != nil {
return UserAppearanceSettings{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserAppearanceSettings{}, ReadBodyAsError(res)
}
var resp UserAppearanceSettings
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// GetUserPreferenceSettings fetches the preference settings for a user.
func (c *Client) GetUserPreferenceSettings(ctx context.Context, user string) (UserPreferenceSettings, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/preferences", user), nil)
if err != nil {
return UserPreferenceSettings{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserPreferenceSettings{}, ReadBodyAsError(res)
}
var resp UserPreferenceSettings
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateUserPreferenceSettings updates the preference settings for a user.
func (c *Client) UpdateUserPreferenceSettings(ctx context.Context, user string, req UpdateUserPreferenceSettingsRequest) (UserPreferenceSettings, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/preferences", user), req)
if err != nil {
return UserPreferenceSettings{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserPreferenceSettings{}, ReadBodyAsError(res)
}
var resp UserPreferenceSettings
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateUserPassword updates a user password.
// It calls PUT /users/{user}/password
func (c *Client) UpdateUserPassword(ctx context.Context, user string, req UpdateUserPasswordRequest) error {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/password", user), req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// PostOrganizationMember adds a user to an organization
func (c *Client) PostOrganizationMember(ctx context.Context, organizationID uuid.UUID, user string) (OrganizationMember, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/members/%s", organizationID, user), nil)
if err != nil {
return OrganizationMember{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return OrganizationMember{}, ReadBodyAsError(res)
}
var member OrganizationMember
return member, json.NewDecoder(res.Body).Decode(&member)
}
// DeleteOrganizationMember removes a user from an organization
func (c *Client) DeleteOrganizationMember(ctx context.Context, organizationID uuid.UUID, user string) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/organizations/%s/members/%s", organizationID, user), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
type OrganizationMembersQuery struct {
UserID uuid.UUID
IncludeSystem bool
GithubUserID int64
}
func (omq OrganizationMembersQuery) AsRequestOption() RequestOption {
return func(r *http.Request) {
q := r.URL.Query()
var sb strings.Builder
if omq.UserID != uuid.Nil {
_, _ = sb.WriteString("user_id:")
_, _ = sb.WriteString(omq.UserID.String())
_, _ = sb.WriteString(" ")
}
if omq.IncludeSystem {
_, _ = sb.WriteString("include_system:true")
}
if omq.GithubUserID != 0 {
_, _ = sb.WriteString("github_user_id:")
_, _ = sb.WriteString(strconv.FormatInt(omq.GithubUserID, 10))
_, _ = sb.WriteString(" ")
}
qs := strings.TrimSpace(sb.String())
if len(qs) == 0 {
return
}
q.Set("q", qs)
r.URL.RawQuery = q.Encode()
}
}
type OrganizationMembersQueryOption func(*OrganizationMembersQuery)
func OrganizationMembersQueryOptionUserID(userID uuid.UUID) OrganizationMembersQueryOption {
return func(query *OrganizationMembersQuery) {
query.UserID = userID
}
}
func OrganizationMembersQueryOptionIncludeSystem() OrganizationMembersQueryOption {
return func(query *OrganizationMembersQuery) {
query.IncludeSystem = true
}
}
func OrganizationMembersQueryOptionGithubUserID(githubUserID int64) OrganizationMembersQueryOption {
return func(query *OrganizationMembersQuery) {
query.GithubUserID = githubUserID
}
}
func (c *Client) OrganizationMember(ctx context.Context, organizationIdent, userIdent string) (OrganizationMemberWithUserData, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/%s", organizationIdent, userIdent), nil)
if err != nil {
return OrganizationMemberWithUserData{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return OrganizationMemberWithUserData{}, ReadBodyAsError(res)
}
var member OrganizationMemberWithUserData
return member, json.NewDecoder(res.Body).Decode(&member)
}
// OrganizationMembers lists all members in an organization
func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UUID, opts ...OrganizationMembersQueryOption) ([]OrganizationMemberWithUserData, error) {
var query OrganizationMembersQuery
for _, opt := range opts {
opt(&query)
}
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/", organizationID), nil, query.AsRequestOption())
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var members []OrganizationMemberWithUserData
return members, json.NewDecoder(res.Body).Decode(&members)
}
// OrganizationMembers lists filtered and paginated members in an organization
func (c *Client) OrganizationMembersPaginated(ctx context.Context, organizationID uuid.UUID, req UsersRequest) (PaginatedMembersResponse, error) {
res, err := c.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/organizations/%s/paginated-members", organizationID),
nil,
req.Pagination.asRequestOption(),
req.asRequestOption(),
)
if err != nil {
return PaginatedMembersResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return PaginatedMembersResponse{}, ReadBodyAsError(res)
}
var membersRes PaginatedMembersResponse
return membersRes, json.NewDecoder(res.Body).Decode(&membersRes)
}
// UpdateUserRoles grants the userID the specified roles.
// Include ALL roles the user has.
func (c *Client) UpdateUserRoles(ctx context.Context, user string, req UpdateRoles) (User, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/roles", user), req)
if err != nil {
return User{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return User{}, ReadBodyAsError(res)
}
var resp User
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateOrganizationMemberRoles grants the userID the specified roles in an org.
// Include ALL roles the user has.
func (c *Client) UpdateOrganizationMemberRoles(ctx context.Context, organizationID uuid.UUID, user string, req UpdateRoles) (OrganizationMember, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/organizations/%s/members/%s/roles", organizationID, user), req)
if err != nil {
return OrganizationMember{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return OrganizationMember{}, ReadBodyAsError(res)
}
var member OrganizationMember
return member, json.NewDecoder(res.Body).Decode(&member)
}
// UserRoles returns all roles the user has
func (c *Client) UserRoles(ctx context.Context, user string) (UserRoles, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/roles", user), nil)
if err != nil {
return UserRoles{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserRoles{}, ReadBodyAsError(res)
}
var roles UserRoles
return roles, json.NewDecoder(res.Body).Decode(&roles)
}
// UserOIDCClaims returns the merged OIDC claims for the authenticated user.
func (c *Client) UserOIDCClaims(ctx context.Context) (OIDCClaimsResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/oidc-claims", nil)
if err != nil {
return OIDCClaimsResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return OIDCClaimsResponse{}, ReadBodyAsError(res)
}
var resp OIDCClaimsResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// LoginWithPassword creates a session token authenticating with an email and password.
// Call `SetSessionToken()` to apply the newly acquired token to the client.
func (c *Client) LoginWithPassword(ctx context.Context, req LoginWithPasswordRequest) (LoginWithPasswordResponse, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/login", req)
if err != nil {
return LoginWithPasswordResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return LoginWithPasswordResponse{}, ReadBodyAsError(res)
}
var resp LoginWithPasswordResponse
err = json.NewDecoder(res.Body).Decode(&resp)
if err != nil {
return LoginWithPasswordResponse{}, err
}
return resp, nil
}
func (c *Client) RequestOneTimePasscode(ctx context.Context, req RequestOneTimePasscodeRequest) error {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/otp/request", req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
func (c *Client) ChangePasswordWithOneTimePasscode(ctx context.Context, req ChangePasswordWithOneTimePasscodeRequest) error {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/otp/change-password", req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// ConvertLoginType will send a request to convert the user from password
// based authentication to oauth based. The response has the oauth state code
// to use in the oauth flow.
func (c *Client) ConvertLoginType(ctx context.Context, req ConvertLoginRequest) (OAuthConversionResponse, error) {
return c.ConvertUserLoginType(ctx, Me, req)
}
// ConvertUserLoginType will send a request to convert the user from password
// based authentication to oauth based. The response has the oauth state code
// to use in the oauth flow.
func (c *Client) ConvertUserLoginType(ctx context.Context, user string, req ConvertLoginRequest) (OAuthConversionResponse, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/convert-login", user), req)
if err != nil {
return OAuthConversionResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return OAuthConversionResponse{}, ReadBodyAsError(res)
}
var resp OAuthConversionResponse
err = json.NewDecoder(res.Body).Decode(&resp)
if err != nil {
return OAuthConversionResponse{}, err
}
return resp, nil
}
// Logout calls the /logout API
// Call `ClearSessionToken()` to clear the session token of the client.
func (c *Client) Logout(ctx context.Context) error {
// Since `LoginWithPassword` doesn't actually set a SessionToken
// (it requires a call to SetSessionToken), this is essentially a no-op
res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/logout", nil)
if err != nil {
return err
}
defer res.Body.Close()
return nil
}
// User returns a user for the ID/username provided.
func (c *Client) User(ctx context.Context, userIdent string) (User, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s", userIdent), nil)
if err != nil {
return User{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return User{}, ReadBodyAsError(res)
}
var user User
return user, json.NewDecoder(res.Body).Decode(&user)
}
// UserQuietHoursSchedule returns the quiet hours settings for the user. This
// endpoint only exists in enterprise editions.
func (c *Client) UserQuietHoursSchedule(ctx context.Context, userIdent string) (UserQuietHoursScheduleResponse, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/quiet-hours", userIdent), nil)
if err != nil {
return UserQuietHoursScheduleResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserQuietHoursScheduleResponse{}, ReadBodyAsError(res)
}
var resp UserQuietHoursScheduleResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateUserQuietHoursSchedule updates the quiet hours settings for the user.
// This endpoint only exists in enterprise editions.
func (c *Client) UpdateUserQuietHoursSchedule(ctx context.Context, userIdent string, req UpdateUserQuietHoursScheduleRequest) (UserQuietHoursScheduleResponse, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/quiet-hours", userIdent), req)
if err != nil {
return UserQuietHoursScheduleResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserQuietHoursScheduleResponse{}, ReadBodyAsError(res)
}
var resp UserQuietHoursScheduleResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// Users returns all users according to the request parameters. If no parameters are set,
// the default behavior is to return all users in a single page.
func (c *Client) Users(ctx context.Context, req UsersRequest) (GetUsersResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/users", nil,
req.Pagination.asRequestOption(),
req.asRequestOption(),
)
if err != nil {
return GetUsersResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GetUsersResponse{}, ReadBodyAsError(res)
}
var usersRes GetUsersResponse
return usersRes, json.NewDecoder(res.Body).Decode(&usersRes)
}
// OrganizationsByUser returns all organizations the user is a member of.
func (c *Client) OrganizationsByUser(ctx context.Context, user string) ([]Organization, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations", user), nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode > http.StatusOK {
return nil, ReadBodyAsError(res)
}
var orgs []Organization
return orgs, json.NewDecoder(res.Body).Decode(&orgs)
}
func (c *Client) OrganizationByUserAndName(ctx context.Context, user string, name string) (Organization, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations/%s", user, name), nil)
if err != nil {
return Organization{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Organization{}, ReadBodyAsError(res)
}
var org Organization
return org, json.NewDecoder(res.Body).Decode(&org)
}
// AuthMethods returns types of authentication available to the user.
func (c *Client) AuthMethods(ctx context.Context) (AuthMethods, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/authmethods", nil)
if err != nil {
return AuthMethods{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AuthMethods{}, ReadBodyAsError(res)
}
var userAuth AuthMethods
return userAuth, json.NewDecoder(res.Body).Decode(&userAuth)
}