satellite/admin: list users pending deletion
This change adds an endpoint to the admin API and UI to get a list of users pending deletion and have no unpaid invoice. Issue: #6410 Change-Id: I906dbf9eee9e7469e45f0c622a891867bf0cc201
This commit is contained in:
parent
405491e8d0
commit
513c3cc632
@ -24,6 +24,7 @@ Requires setting `Authorization` header for requests.
|
||||
* [PUT /api/users/{user-email}/violation-freeze](#put-apiusersuser-emailviolation-freeze)
|
||||
* [DELETE /api/users/{user-email}/violation-freeze](#delete-apiusersuser-emailviolation-freeze)
|
||||
* [DELETE /api/users/{user-email}/billing-warning](#delete-apiusersuser-emailbilling-warning)
|
||||
* [GET /api/users/pending-deletion](#get-apiuserspending-deletion)
|
||||
* [PATCH /api/users/{user-email}/geofence](#patch-apiusersuser-emailgeofence)
|
||||
* [DELETE /api/users/{user-email}/geofence](#delete-apiusersuser-emailgeofence)
|
||||
* [OAuth Client Management](#oauth-client-management)
|
||||
@ -199,6 +200,12 @@ User status is set back to Active. This is the only way to exit the violation fr
|
||||
|
||||
Removes the billing warning status from a user's account.
|
||||
|
||||
#### GET /api/users/pending-deletion
|
||||
|
||||
Returns a limited list of users pending deletion and have no unpaid invoices.
|
||||
Required parameters: `limit` and `page`.
|
||||
Example: `/api/users/pending-deletion?limit=10&page=1`
|
||||
|
||||
#### PATCH /api/users/{user-email}/geofence
|
||||
|
||||
Sets the account level geofence for the user.
|
||||
@ -454,7 +461,7 @@ A successful response body:
|
||||
},
|
||||
"project": {
|
||||
"id": "12345678-1234-1234-1234-123456789abc",
|
||||
"name": "My Project",
|
||||
"name": "My Project"
|
||||
},
|
||||
"owner": {
|
||||
"id": "12345678-1234-1234-1234-123456789abc",
|
||||
|
@ -174,6 +174,7 @@ func NewServer(
|
||||
limitUpdateAPI.HandleFunc("/users/{useremail}/billing-warning", server.billingUnWarnUser).Methods("DELETE")
|
||||
limitUpdateAPI.HandleFunc("/users/{useremail}/violation-freeze", server.violationFreezeUser).Methods("PUT")
|
||||
limitUpdateAPI.HandleFunc("/users/{useremail}/violation-freeze", server.violationUnfreezeUser).Methods("DELETE")
|
||||
limitUpdateAPI.HandleFunc("/users/pending-deletion", server.usersPendingDeletion).Methods("GET")
|
||||
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.getProjectLimit).Methods("GET")
|
||||
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.putProjectLimit).Methods("PUT", "POST")
|
||||
|
||||
|
@ -343,6 +343,17 @@ export class Admin {
|
||||
return this.fetch('GET', `users/${email}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get users pending deletion',
|
||||
desc: 'Get the information of a users pending deletion and have no unpaid invoices',
|
||||
params: [
|
||||
['Limit', new InputText('number', true)],
|
||||
['Page', new InputText('number', true)]
|
||||
],
|
||||
func: async (limit: number, page: number): Promise<Record<string, unknown>> => {
|
||||
return this.fetch('GET', `users-pending-deletion?limit=${limit}&page=${page}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get user limits',
|
||||
desc: 'Get the current limits for a user',
|
||||
|
@ -206,6 +206,96 @@ func (server *Server) userInfo(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSONData(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (server *Server) usersPendingDeletion(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
FullName string `json:"fullName"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
|
||||
limitParam := query.Get("limit")
|
||||
if limitParam == "" {
|
||||
sendJSONError(w, "Bad request", "parameter 'limit' can't be empty", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
limit, err := strconv.ParseUint(limitParam, 10, 32)
|
||||
if err != nil {
|
||||
sendJSONError(w, "Bad request", err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pageParam := query.Get("page")
|
||||
if pageParam == "" {
|
||||
sendJSONError(w, "Bad request", "parameter 'page' can't be empty", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page, err := strconv.ParseUint(pageParam, 10, 32)
|
||||
if err != nil {
|
||||
sendJSONError(w, "Bad request", err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var sendingPage struct {
|
||||
Users []User `json:"users"`
|
||||
PageCount uint `json:"pageCount"`
|
||||
CurrentPage uint `json:"currentPage"`
|
||||
TotalCount uint64 `json:"totalCount"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
usersPage, err := server.db.Console().Users().GetByStatus(
|
||||
ctx, console.PendingDeletion, console.UserCursor{
|
||||
Limit: uint(limit),
|
||||
Page: uint(page),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
sendJSONError(w, "failed retrieving a usersPage of users", err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sendingPage.PageCount = usersPage.PageCount
|
||||
sendingPage.CurrentPage = usersPage.CurrentPage
|
||||
sendingPage.TotalCount = usersPage.TotalCount
|
||||
sendingPage.Users = make([]User, 0, len(usersPage.Users))
|
||||
|
||||
if sendingPage.PageCount > sendingPage.CurrentPage {
|
||||
sendingPage.HasMore = true
|
||||
}
|
||||
|
||||
for _, user := range usersPage.Users {
|
||||
invoices, err := server.payments.Invoices().ListFailed(ctx, &user.ID)
|
||||
if err != nil {
|
||||
sendJSONError(w, "getting invoices failed",
|
||||
err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(invoices) != 0 {
|
||||
sendingPage.TotalCount--
|
||||
continue
|
||||
}
|
||||
sendingPage.Users = append(sendingPage.Users, User{
|
||||
ID: user.ID,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
})
|
||||
}
|
||||
|
||||
data, err := json.Marshal(sendingPage)
|
||||
if err != nil {
|
||||
sendJSONError(w, "json encoding failed",
|
||||
err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sendJSONData(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (server *Server) userLimits(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
|
@ -30,6 +30,8 @@ type Users interface {
|
||||
UpdateFailedLoginCountAndExpiration(ctx context.Context, failedLoginPenalty *float64, id uuid.UUID) error
|
||||
// GetByEmailWithUnverified is a method for querying users by email from the database.
|
||||
GetByEmailWithUnverified(ctx context.Context, email string) (*User, []User, error)
|
||||
// GetByStatus is a method for querying user by status from the database.
|
||||
GetByStatus(ctx context.Context, status UserStatus, cursor UserCursor) (*UsersPage, error)
|
||||
// GetByEmail is a method for querying user by verified email from the database.
|
||||
GetByEmail(ctx context.Context, email string) (*User, error)
|
||||
// Insert is a method for inserting user into the database.
|
||||
@ -66,6 +68,24 @@ type UserInfo struct {
|
||||
ShortName string `json:"shortName"`
|
||||
}
|
||||
|
||||
// UserCursor holds info for user info cursor pagination.
|
||||
type UserCursor struct {
|
||||
Limit uint `json:"limit"`
|
||||
Page uint `json:"page"`
|
||||
}
|
||||
|
||||
// UsersPage represent user info page result.
|
||||
type UsersPage struct {
|
||||
Users []User `json:"users"`
|
||||
|
||||
Limit uint `json:"limit"`
|
||||
Offset uint64 `json:"offset"`
|
||||
|
||||
PageCount uint `json:"pageCount"`
|
||||
CurrentPage uint `json:"currentPage"`
|
||||
TotalCount uint64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// IsValid checks UserInfo validity and returns error describing whats wrong.
|
||||
// The returned error has the class ErrValiation.
|
||||
func (user *UserInfo) IsValid() error {
|
||||
|
@ -336,6 +336,53 @@ func TestGetUserByEmail(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetUsersByStatus(t *testing.T) {
|
||||
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
|
||||
usersRepo := db.Console().Users()
|
||||
|
||||
inactiveUser := console.User{
|
||||
ID: testrand.UUID(),
|
||||
FullName: "Inactive User",
|
||||
Email: email,
|
||||
PasswordHash: []byte("123a123"),
|
||||
}
|
||||
|
||||
_, err := usersRepo.Insert(ctx, &inactiveUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
activeUser := console.User{
|
||||
ID: testrand.UUID(),
|
||||
FullName: "Active User",
|
||||
Email: email,
|
||||
Status: console.Active,
|
||||
PasswordHash: []byte("123a123"),
|
||||
}
|
||||
|
||||
_, err = usersRepo.Insert(ctx, &activeUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Required to set the active status.
|
||||
err = usersRepo.Update(ctx, activeUser.ID, console.UpdateUserRequest{
|
||||
Status: &activeUser.Status,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cursor := console.UserCursor{
|
||||
Limit: 50,
|
||||
Page: 1,
|
||||
}
|
||||
usersPage, err := usersRepo.GetByStatus(ctx, console.Inactive, cursor)
|
||||
require.NoError(t, err)
|
||||
require.Lenf(t, usersPage.Users, 1, "expected 1 inactive user")
|
||||
require.Equal(t, inactiveUser.ID, usersPage.Users[0].ID)
|
||||
|
||||
usersPage, err = usersRepo.GetByStatus(ctx, console.Active, cursor)
|
||||
require.NoError(t, err)
|
||||
require.Lenf(t, usersPage.Users, 1, "expected 1 active user")
|
||||
require.Equal(t, activeUser.ID, usersPage.Users[0].ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetUnverifiedNeedingReminder(t *testing.T) {
|
||||
testplanet.Run(t, testplanet.Config{
|
||||
Reconfigure: testplanet.Reconfigure{
|
||||
|
@ -11992,6 +11992,12 @@ type CustomerId_Row struct {
|
||||
CustomerId string
|
||||
}
|
||||
|
||||
type Id_Email_FullName_Row struct {
|
||||
Id []byte
|
||||
Email string
|
||||
FullName string
|
||||
}
|
||||
|
||||
type Id_PieceCount_Row struct {
|
||||
Id []byte
|
||||
PieceCount int64
|
||||
@ -16871,6 +16877,77 @@ func (obj *pgxImpl) Get_User_ProjectStorageLimit_User_ProjectBandwidthLimit_User
|
||||
|
||||
}
|
||||
|
||||
func (obj *pgxImpl) Count_User_By_Status(ctx context.Context,
|
||||
user_status User_Status_Field) (
|
||||
count int64, err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
var __embed_stmt = __sqlbundle_Literal("SELECT COUNT(*) FROM users WHERE users.status = ?")
|
||||
|
||||
var __values []interface{}
|
||||
__values = append(__values, user_status.value())
|
||||
|
||||
var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt)
|
||||
obj.logStmt(__stmt, __values...)
|
||||
|
||||
err = obj.queryRowContext(ctx, __stmt, __values...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, obj.makeErr(err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
|
||||
}
|
||||
|
||||
func (obj *pgxImpl) Limited_User_Id_User_Email_User_FullName_By_Status(ctx context.Context,
|
||||
user_status User_Status_Field,
|
||||
limit int, offset int64) (
|
||||
rows []*Id_Email_FullName_Row, err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
var __embed_stmt = __sqlbundle_Literal("SELECT users.id, users.email, users.full_name FROM users WHERE users.status = ? LIMIT ? OFFSET ?")
|
||||
|
||||
var __values []interface{}
|
||||
__values = append(__values, user_status.value())
|
||||
|
||||
__values = append(__values, limit, offset)
|
||||
|
||||
var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt)
|
||||
obj.logStmt(__stmt, __values...)
|
||||
|
||||
for {
|
||||
rows, err = func() (rows []*Id_Email_FullName_Row, err error) {
|
||||
__rows, err := obj.driver.QueryContext(ctx, __stmt, __values...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer __rows.Close()
|
||||
|
||||
for __rows.Next() {
|
||||
row := &Id_Email_FullName_Row{}
|
||||
err = __rows.Scan(&row.Id, &row.Email, &row.FullName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
err = __rows.Err()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}()
|
||||
if err != nil {
|
||||
if obj.shouldRetry(err) {
|
||||
continue
|
||||
}
|
||||
return nil, obj.makeErr(err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (obj *pgxImpl) All_WebappSession_By_UserId(ctx context.Context,
|
||||
webapp_session_user_id WebappSession_UserId_Field) (
|
||||
rows []*WebappSession, err error) {
|
||||
@ -25188,6 +25265,77 @@ func (obj *pgxcockroachImpl) Get_User_ProjectStorageLimit_User_ProjectBandwidthL
|
||||
|
||||
}
|
||||
|
||||
func (obj *pgxcockroachImpl) Count_User_By_Status(ctx context.Context,
|
||||
user_status User_Status_Field) (
|
||||
count int64, err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
var __embed_stmt = __sqlbundle_Literal("SELECT COUNT(*) FROM users WHERE users.status = ?")
|
||||
|
||||
var __values []interface{}
|
||||
__values = append(__values, user_status.value())
|
||||
|
||||
var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt)
|
||||
obj.logStmt(__stmt, __values...)
|
||||
|
||||
err = obj.queryRowContext(ctx, __stmt, __values...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, obj.makeErr(err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
|
||||
}
|
||||
|
||||
func (obj *pgxcockroachImpl) Limited_User_Id_User_Email_User_FullName_By_Status(ctx context.Context,
|
||||
user_status User_Status_Field,
|
||||
limit int, offset int64) (
|
||||
rows []*Id_Email_FullName_Row, err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
var __embed_stmt = __sqlbundle_Literal("SELECT users.id, users.email, users.full_name FROM users WHERE users.status = ? LIMIT ? OFFSET ?")
|
||||
|
||||
var __values []interface{}
|
||||
__values = append(__values, user_status.value())
|
||||
|
||||
__values = append(__values, limit, offset)
|
||||
|
||||
var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt)
|
||||
obj.logStmt(__stmt, __values...)
|
||||
|
||||
for {
|
||||
rows, err = func() (rows []*Id_Email_FullName_Row, err error) {
|
||||
__rows, err := obj.driver.QueryContext(ctx, __stmt, __values...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer __rows.Close()
|
||||
|
||||
for __rows.Next() {
|
||||
row := &Id_Email_FullName_Row{}
|
||||
err = __rows.Scan(&row.Id, &row.Email, &row.FullName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
err = __rows.Err()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}()
|
||||
if err != nil {
|
||||
if obj.shouldRetry(err) {
|
||||
continue
|
||||
}
|
||||
return nil, obj.makeErr(err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (obj *pgxcockroachImpl) All_WebappSession_By_UserId(ctx context.Context,
|
||||
webapp_session_user_id WebappSession_UserId_Field) (
|
||||
rows []*WebappSession, err error) {
|
||||
@ -28875,6 +29023,10 @@ type Methods interface {
|
||||
bucket_metainfo_project_id BucketMetainfo_ProjectId_Field) (
|
||||
count int64, err error)
|
||||
|
||||
Count_User_By_Status(ctx context.Context,
|
||||
user_status User_Status_Field) (
|
||||
count int64, err error)
|
||||
|
||||
CreateNoReturn_BillingBalance(ctx context.Context,
|
||||
billing_balance_user_id BillingBalance_UserId_Field,
|
||||
billing_balance_balance BillingBalance_Balance_Field) (
|
||||
@ -29481,6 +29633,11 @@ type Methods interface {
|
||||
limit int, offset int64) (
|
||||
rows []*StorjscanPayment, err error)
|
||||
|
||||
Limited_User_Id_User_Email_User_FullName_By_Status(ctx context.Context,
|
||||
user_status User_Status_Field,
|
||||
limit int, offset int64) (
|
||||
rows []*Id_Email_FullName_Row, err error)
|
||||
|
||||
Paged_BucketBandwidthRollupArchive_By_IntervalStart_GreaterOrEqual(ctx context.Context,
|
||||
bucket_bandwidth_rollup_archive_interval_start_greater_or_equal BucketBandwidthRollupArchive_IntervalStart_Field,
|
||||
limit int, start *Paged_BucketBandwidthRollupArchive_By_IntervalStart_GreaterOrEqual_Continuation) (
|
||||
|
@ -112,6 +112,16 @@ read one (
|
||||
where user.id = ?
|
||||
)
|
||||
|
||||
read count (
|
||||
select user
|
||||
where user.status = ?
|
||||
)
|
||||
|
||||
read limitoffset (
|
||||
select user.id user.email user.full_name
|
||||
where user.status = ?
|
||||
)
|
||||
|
||||
model webapp_session (
|
||||
key id
|
||||
index ( fields user_id )
|
||||
|
@ -88,6 +88,71 @@ func (users *users) GetByEmailWithUnverified(ctx context.Context, email string)
|
||||
return verified, unverified, errors.Err()
|
||||
}
|
||||
|
||||
func (users *users) GetByStatus(ctx context.Context, status console.UserStatus, cursor console.UserCursor) (page *console.UsersPage, err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
if cursor.Limit == 0 {
|
||||
return nil, Error.New("limit cannot be 0")
|
||||
}
|
||||
|
||||
if cursor.Page == 0 {
|
||||
return nil, Error.New("page cannot be 0")
|
||||
}
|
||||
|
||||
page = &console.UsersPage{
|
||||
Limit: cursor.Limit,
|
||||
Offset: uint64((cursor.Page - 1) * cursor.Limit),
|
||||
}
|
||||
|
||||
count, err := users.db.Count_User_By_Status(ctx, dbx.User_Status(int(status)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page.TotalCount = uint64(count)
|
||||
|
||||
if page.TotalCount == 0 {
|
||||
return page, nil
|
||||
}
|
||||
if page.Offset > page.TotalCount-1 {
|
||||
return nil, Error.New("page is out of range")
|
||||
}
|
||||
|
||||
dbxUsers, err := users.db.Limited_User_Id_User_Email_User_FullName_By_Status(ctx,
|
||||
dbx.User_Status(int(status)),
|
||||
int(page.Limit), int64(page.Offset))
|
||||
if err != nil {
|
||||
if errs.Is(err, sql.ErrNoRows) {
|
||||
return &console.UsersPage{
|
||||
Users: []console.User{},
|
||||
}, nil
|
||||
}
|
||||
return nil, Error.Wrap(err)
|
||||
}
|
||||
|
||||
for _, usr := range dbxUsers {
|
||||
id, err := uuid.FromBytes(usr.Id)
|
||||
if err != nil {
|
||||
return &console.UsersPage{
|
||||
Users: []console.User{},
|
||||
}, nil
|
||||
}
|
||||
page.Users = append(page.Users, console.User{
|
||||
ID: id,
|
||||
Email: usr.Email,
|
||||
FullName: usr.FullName,
|
||||
})
|
||||
}
|
||||
|
||||
page.PageCount = uint(page.TotalCount / uint64(cursor.Limit))
|
||||
if page.TotalCount%uint64(cursor.Limit) != 0 {
|
||||
page.PageCount++
|
||||
}
|
||||
|
||||
page.CurrentPage = cursor.Page
|
||||
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// GetByEmail is a method for querying user by verified email from the database.
|
||||
func (users *users) GetByEmail(ctx context.Context, email string) (_ *console.User, err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
Loading…
Reference in New Issue
Block a user