diff --git a/cmd/server/pactasrv/conv/oapi_to_pacta.go b/cmd/server/pactasrv/conv/oapi_to_pacta.go index bd2bf5c..ae6b1b5 100644 --- a/cmd/server/pactasrv/conv/oapi_to_pacta.go +++ b/cmd/server/pactasrv/conv/oapi_to_pacta.go @@ -314,3 +314,35 @@ func AuditLogQueryFromOAPI(q *api.AuditLogQueryReq) (*db.AuditLogQuery, error) { Sorts: sorts, }, nil } + +func userQueryWhereFromOAPI(i api.UserQueryWhere) (*db.UserQueryWhere, error) { + result := &db.UserQueryWhere{} + if i.NameOrEmailLike != nil { + result.NameOrEmailLike = *i.NameOrEmailLike + } + return result, nil +} + +func UserQueryFromOAPI(q *api.UserQueryReq) (*db.UserQuery, error) { + limit := 100 + cursor := "" + if q.Cursor != nil { + cursor = *q.Cursor + } + wheres := []api.UserQueryWhere{} + if q.Wheres != nil { + wheres = append(wheres, *q.Wheres...) + } + ws, err := convAll(wheres, userQueryWhereFromOAPI) + if err != nil { + return nil, oapierr.BadRequest("error converting user query wheres", zap.Error(err)) + } + return &db.UserQuery{ + Cursor: db.Cursor(cursor), + Limit: limit, + Wheres: ws, + Sorts: []*db.UserQuerySort{ + {By: db.UserQuerySortBy_CreatedAt, Ascending: false}, + }, + }, nil +} diff --git a/cmd/server/pactasrv/conv/pacta_to_oapi.go b/cmd/server/pactasrv/conv/pacta_to_oapi.go index 258d7a4..54831b5 100644 --- a/cmd/server/pactasrv/conv/pacta_to_oapi.go +++ b/cmd/server/pactasrv/conv/pacta_to_oapi.go @@ -96,6 +96,10 @@ func portfolioInitiativeMembershipToOAPIInitiative(in *pacta.PortfolioInitiative return out, nil } +func UsersToOAPI(users []*pacta.User) ([]*api.User, error) { + return convAll(users, UserToOAPI) +} + func UserToOAPI(user *pacta.User) (*api.User, error) { if user == nil { return nil, oapierr.Internal("userToOAPI: can't convert nil pointer") diff --git a/cmd/server/pactasrv/pactasrv.go b/cmd/server/pactasrv/pactasrv.go index b50ebe7..482c54c 100644 --- a/cmd/server/pactasrv/pactasrv.go +++ b/cmd/server/pactasrv/pactasrv.go @@ -117,6 +117,7 @@ type DB interface { Users(tx db.Tx, ids []pacta.UserID) (map[pacta.UserID]*pacta.User, error) UpdateUser(tx db.Tx, id pacta.UserID, mutations ...db.UpdateUserFn) error DeleteUser(tx db.Tx, id pacta.UserID) ([]pacta.BlobURI, error) + QueryUsers(tx db.Tx, q *db.UserQuery) ([]*pacta.User, *db.PageInfo, error) CreateAuditLog(tx db.Tx, a *pacta.AuditLog) (pacta.AuditLogID, error) CreateAuditLogs(tx db.Tx, as []*pacta.AuditLog) error diff --git a/cmd/server/pactasrv/user.go b/cmd/server/pactasrv/user.go index 459eb86..ecb346d 100644 --- a/cmd/server/pactasrv/user.go +++ b/cmd/server/pactasrv/user.go @@ -152,6 +152,34 @@ func (s *Server) UserAuthenticationFollowup(ctx context.Context, _request api.Us return api.UserAuthenticationFollowup200JSONResponse(*result), nil } +// (GET /users) +func (s *Server) UserQuery(ctx context.Context, request api.UserQueryRequestObject) (api.UserQueryResponseObject, error) { + actorInfo, err := s.getActorInfoOrErrIfAnon(ctx) + if err != nil { + return nil, err + } + if !actorInfo.IsAdmin && !actorInfo.IsSuperAdmin { + return nil, oapierr.Unauthorized("only admins can list users") + } + q, err := conv.UserQueryFromOAPI(request.Body) + if err != nil { + return nil, err + } + us, pi, err := s.DB.QueryUsers(s.DB.NoTxn(ctx), q) + if err != nil { + return nil, oapierr.Internal("failed to query users", zap.Error(err)) + } + users, err := dereference(conv.UsersToOAPI(us)) + if err != nil { + return nil, err + } + return api.UserQuery200JSONResponse{ + Users: users, + Cursor: string(pi.Cursor), + HasNextPage: pi.HasNextPage, + }, nil +} + func (s *Server) userDoAuthzAndAuditLog(ctx context.Context, targetUserID pacta.UserID, action pacta.AuditLogAction) error { actorInfo, err := s.getActorInfoOrErrIfAnon(ctx) if err != nil { diff --git a/db/queries.go b/db/queries.go index ab1c656..6764218 100644 --- a/db/queries.go +++ b/db/queries.go @@ -79,3 +79,25 @@ type AuditLogQuery struct { Wheres []*AuditLogQueryWhere Sorts []*AuditLogQuerySort } + +type UserQuerySortBy string + +const ( + UserQuerySortBy_CreatedAt UserQuerySortBy = "created_at" +) + +type UserQuerySort struct { + By UserQuerySortBy + Ascending bool +} + +type UserQueryWhere struct { + NameOrEmailLike string +} + +type UserQuery struct { + Cursor Cursor + Limit int + Wheres []*UserQueryWhere + Sorts []*UserQuerySort +} diff --git a/db/sqldb/golden/human_readable_schema.sql b/db/sqldb/golden/human_readable_schema.sql index 41fe6ec..ca375e4 100644 --- a/db/sqldb/golden/human_readable_schema.sql +++ b/db/sqldb/golden/human_readable_schema.sql @@ -206,6 +206,8 @@ ALTER TABLE ONLY pacta_user ADD CONSTRAINT pacta_user_authn_mechanism_authn_id_k ALTER TABLE ONLY pacta_user ADD CONSTRAINT pacta_user_canonical_email_key UNIQUE (canonical_email); ALTER TABLE ONLY pacta_user ADD CONSTRAINT pacta_user_entered_email_key UNIQUE (entered_email); ALTER TABLE ONLY pacta_user ADD CONSTRAINT pacta_user_pkey PRIMARY KEY (id); +CREATE INDEX user_canonical_email_gin_index ON pacta_user USING gin (canonical_email gin_trgm_ops); +CREATE INDEX user_name_gin_index ON pacta_user USING gin (name gin_trgm_ops); CREATE TABLE pacta_version ( diff --git a/db/sqldb/golden/schema_dump.sql b/db/sqldb/golden/schema_dump.sql index c3c8187..1494fca 100644 --- a/db/sqldb/golden/schema_dump.sql +++ b/db/sqldb/golden/schema_dump.sql @@ -16,6 +16,20 @@ SET xmloption = content; SET client_min_messages = warning; SET row_security = off; +-- +-- Name: pg_trgm; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public; + + +-- +-- Name: EXTENSION pg_trgm; Type: COMMENT; Schema: -; Owner: +-- + +COMMENT ON EXTENSION pg_trgm IS 'text similarity measurement and index searching based on trigrams'; + + -- -- Name: analysis_type; Type: TYPE; Schema: public; Owner: postgres -- @@ -749,6 +763,20 @@ CREATE INDEX owner_by_user_id ON public.owner USING btree (user_id); CREATE INDEX portfolio_by_blob_id ON public.portfolio USING btree (blob_id); +-- +-- Name: user_canonical_email_gin_index; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX user_canonical_email_gin_index ON public.pacta_user USING gin (canonical_email public.gin_trgm_ops); + + +-- +-- Name: user_name_gin_index; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX user_name_gin_index ON public.pacta_user USING gin (name public.gin_trgm_ops); + + -- -- Name: schema_migrations track_applied_migrations; Type: TRIGGER; Schema: public; Owner: postgres -- diff --git a/db/sqldb/migrations/0013_user_search.down.sql b/db/sqldb/migrations/0013_user_search.down.sql new file mode 100644 index 0000000..05ceb42 --- /dev/null +++ b/db/sqldb/migrations/0013_user_search.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +DROP INDEX user_name_gin_index; +DROP INDEX user_canonical_email_gin_index; + +COMMIT; \ No newline at end of file diff --git a/db/sqldb/migrations/0013_user_search.up.sql b/db/sqldb/migrations/0013_user_search.up.sql new file mode 100644 index 0000000..c3dd944 --- /dev/null +++ b/db/sqldb/migrations/0013_user_search.up.sql @@ -0,0 +1,9 @@ +BEGIN; + +CREATE EXTENSION IF NOT EXISTS pg_trgm; +SET pg_trgm.similarity_threshold = 0.1; + +CREATE INDEX user_name_gin_index ON pacta_user USING gin (name gin_trgm_ops); +CREATE INDEX user_canonical_email_gin_index ON pacta_user USING gin (canonical_email gin_trgm_ops); + +COMMIT; \ No newline at end of file diff --git a/db/sqldb/sqldb_test.go b/db/sqldb/sqldb_test.go index f888b04..8c0f576 100644 --- a/db/sqldb/sqldb_test.go +++ b/db/sqldb/sqldb_test.go @@ -94,6 +94,7 @@ func TestSchemaHistory(t *testing.T) { {ID: 10, Version: 10}, // 0010_audit_log_enum_values {ID: 11, Version: 11}, // 0011_add_report_file_types {ID: 12, Version: 12}, // 0012_portfolio_properties + {ID: 13, Version: 13}, // 0013_user_search } if diff := cmp.Diff(want, got); diff != "" { diff --git a/db/sqldb/user.go b/db/sqldb/user.go index 670bc05..7c505a9 100644 --- a/db/sqldb/user.go +++ b/db/sqldb/user.go @@ -2,6 +2,7 @@ package sqldb import ( "fmt" + "strings" "github.com/RMI/pacta/db" "github.com/RMI/pacta/pacta" @@ -105,6 +106,84 @@ func (d *DB) Users(tx db.Tx, ids []pacta.UserID) (map[pacta.UserID]*pacta.User, return result, nil } +func (d *DB) QueryUsers(tx db.Tx, q *db.UserQuery) ([]*pacta.User, *db.PageInfo, error) { + if q.Limit <= 0 { + return nil, nil, fmt.Errorf("limit must be greater than 0, was %d", q.Limit) + } + offset, err := offsetFromCursor(q.Cursor) + if err != nil { + return nil, nil, fmt.Errorf("converting cursor to offset: %w", err) + } + sql, args, err := userQuery(q) + if err != nil { + return nil, nil, fmt.Errorf("building user query: %w", err) + } + rows, err := d.query(tx, sql, args...) + if err != nil { + return nil, nil, fmt.Errorf("executing user query: %w", err) + } + us, err := rowsToUsers(rows) + if err != nil { + return nil, nil, fmt.Errorf("getting users from rows: %w", err) + } + // This will incorrectly say "yes there are more results" if we happen to hit the actual limit, but + // that's a pretty small performance loss. + hasNextPage := len(us) == q.Limit + cursor := offsetToCursor(offset + len(us)) + return us, &db.PageInfo{HasNextPage: hasNextPage, Cursor: db.Cursor(cursor)}, nil +} + +func userQuery(q *db.UserQuery) (string, []any, error) { + args := &queryArgs{} + selectFrom := `SELECT ` + userSelectColumns + ` FROM pacta_user` + where := userQueryWheresToSQL(q.Wheres, args) + order := userQuerySortsToSQL(q.Sorts) + limit := fmt.Sprintf("LIMIT %d", q.Limit) + offset := "" + if q.Cursor != "" { + o, err := offsetFromCursor(q.Cursor) + if err != nil { + return "", nil, fmt.Errorf("extracting offset from cursor in audit-log query: %w", err) + } + offset = fmt.Sprintf("OFFSET %d", o) + } + sql := fmt.Sprintf("%s %s %s %s %s;", selectFrom, where, order, limit, offset) + return sql, args.values, nil +} + +func userQuerySortsToSQL(ss []*db.UserQuerySort) string { + sorts := []string{} + for _, s := range ss { + v := " DESC" + if s.Ascending { + v = " ASC" + } + sorts = append(sorts, fmt.Sprintf("pacta_user.%s %s", s.By, v)) + } + // Forces a deterministic sort for pagination. + sorts = append(sorts, "pacta_user.id ASC") + return "ORDER BY " + strings.Join(sorts, ", ") +} + +func userQueryWheresToSQL(qs []*db.UserQueryWhere, args *queryArgs) string { + wheres := []string{} + for _, q := range qs { + if q.NameOrEmailLike != "" { + wheres = append(wheres, + fmt.Sprintf( + `name ILIKE ('%[1]s' || %[2]s || '%[1]s') + OR + canonical_email ILIKE ('%[1]s' || %[2]s || '%[1]s')`, + "%", + args.add(q.NameOrEmailLike))) + } + } + if len(wheres) == 0 { + return "" + } + return "WHERE " + strings.Join(wheres, " AND ") +} + func (d *DB) createUser(tx db.Tx, u *pacta.User) (pacta.UserID, error) { if err := validateUserForCreation(u); err != nil { return "", fmt.Errorf("validating user for creation: %w", err) diff --git a/db/sqldb/user_test.go b/db/sqldb/user_test.go index 22054c9..cbf563c 100644 --- a/db/sqldb/user_test.go +++ b/db/sqldb/user_test.go @@ -148,6 +148,171 @@ func TestUpdateUser(t *testing.T) { } } +func TestQueryUsers(t *testing.T) { + ctx := context.Background() + tdb := createDBForTesting(t) + tx := tdb.NoTxn(ctx) + userA := &pacta.User{ + Name: "Assitant Regional Manager Schrute", + AuthnMechanism: pacta.AuthnMechanism_EmailAndPass, + AuthnID: "AAA", + CanonicalEmail: "dwight@dm.com", + EnteredEmail: "something-else", + } + userB := &pacta.User{ + Name: "Jim", + AuthnMechanism: pacta.AuthnMechanism_EmailAndPass, + AuthnID: "BBB", + CanonicalEmail: "jim@dm.com", + EnteredEmail: "entered2", + } + userC := &pacta.User{ + Name: "DWIGHT SCHRUTE, FARMER", + AuthnMechanism: pacta.AuthnMechanism_EmailAndPass, + AuthnID: "CCC", + CanonicalEmail: "beets-for-sale-northern-pa@gmail.com", + EnteredEmail: "entered3", + } + userIDA, err0 := tdb.createUser(tx, userA) + userA.ID = userIDA + userA.CreatedAt = time.Now() + userIDB, err1 := tdb.createUser(tx, userB) + userB.ID = userIDB + userB.CreatedAt = time.Now() + userIDC, err2 := tdb.createUser(tx, userC) + userC.ID = userIDC + userC.CreatedAt = time.Now() + noErrDuringSetup(t, err0, err1, err2) + + testCases := []struct { + name string + query *db.UserQuery + expected []pacta.UserID + expectedMore bool + expectedCursor string + }{ + { + name: "Sort Asc", + query: &db.UserQuery{ + Sorts: []*db.UserQuerySort{{By: db.UserQuerySortBy_CreatedAt, Ascending: true}}, + Limit: 4, + }, + expected: []pacta.UserID{userIDA, userIDB, userIDC}, + expectedMore: false, + }, + { + name: "Sort Desc", + query: &db.UserQuery{ + Sorts: []*db.UserQuerySort{{By: db.UserQuerySortBy_CreatedAt, Ascending: false}}, + Limit: 4, + }, + expected: []pacta.UserID{userIDC, userIDB, userIDA}, + expectedMore: false, + }, + { + name: "Limit Enforced", + query: &db.UserQuery{ + Sorts: []*db.UserQuerySort{{By: db.UserQuerySortBy_CreatedAt, Ascending: false}}, + Limit: 2, + }, + expected: []pacta.UserID{userIDC, userIDB}, + expectedMore: true, + expectedCursor: "2", + }, + { + name: "With Cursor", + query: &db.UserQuery{ + Sorts: []*db.UserQuerySort{{By: db.UserQuerySortBy_CreatedAt, Ascending: false}}, + Limit: 2, + Cursor: "2", + }, + expected: []pacta.UserID{userIDA}, + expectedMore: false, + }, + { + name: "Dwight LC", + query: &db.UserQuery{ + Sorts: []*db.UserQuerySort{{By: db.UserQuerySortBy_CreatedAt, Ascending: true}}, + Limit: 4, + Wheres: []*db.UserQueryWhere{{NameOrEmailLike: "dwight"}}, + }, + expected: []pacta.UserID{userIDA, userIDC}, + expectedMore: false, + }, + { + name: "Schrute", + query: &db.UserQuery{ + Sorts: []*db.UserQuerySort{{By: db.UserQuerySortBy_CreatedAt, Ascending: true}}, + Limit: 4, + Wheres: []*db.UserQueryWhere{{NameOrEmailLike: "schrute"}}, + }, + expected: []pacta.UserID{userIDA, userIDC}, + expectedMore: false, + }, + { + name: "Dwight Partial", + query: &db.UserQuery{ + Sorts: []*db.UserQuerySort{{By: db.UserQuerySortBy_CreatedAt, Ascending: true}}, + Limit: 4, + Wheres: []*db.UserQueryWhere{{NameOrEmailLike: "wigh"}}, + }, + expected: []pacta.UserID{userIDA, userIDC}, + expectedMore: false, + }, + { + name: "Dwight Spongebob", + query: &db.UserQuery{ + Sorts: []*db.UserQuerySort{{By: db.UserQuerySortBy_CreatedAt, Ascending: true}}, + Limit: 4, + Wheres: []*db.UserQueryWhere{{NameOrEmailLike: "dWiGhT"}}, + }, + expected: []pacta.UserID{userIDA, userIDC}, + expectedMore: false, + }, + { + name: "Dunder Miflin", + query: &db.UserQuery{ + Sorts: []*db.UserQuerySort{{By: db.UserQuerySortBy_CreatedAt, Ascending: true}}, + Limit: 4, + Wheres: []*db.UserQueryWhere{{NameOrEmailLike: "dm.com"}}, + }, + expected: []pacta.UserID{userIDA, userIDB}, + expectedMore: false, + }, + { + name: "Jim", + query: &db.UserQuery{ + Limit: 4, + Wheres: []*db.UserQueryWhere{{NameOrEmailLike: "jim"}}, + }, + expected: []pacta.UserID{userIDB}, + expectedMore: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + users, pi, err := tdb.QueryUsers(nil, tc.query) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + userIDs := make([]pacta.UserID, len(users)) + for i, user := range users { + userIDs[i] = user.ID + } + if diff := cmp.Diff(tc.expected, userIDs); diff != "" { + t.Errorf("Expected and actual users do not match. Expected: %v, Actual: %v:\n%s\nDecodingMap = %+v", tc.expected, userIDs, diff, map[pacta.UserID]string{userIDA: "Dwight DM", userIDB: "Jim DM", userIDC: "Dwight Personal"}) + } + if pi.HasNextPage != tc.expectedMore { + t.Errorf("Expected HasNextPage to be %v, got %v", tc.expectedMore, pi.HasNextPage) + } + if tc.expectedCursor != "" && string(pi.Cursor) != tc.expectedCursor { + t.Errorf("Expected cursor to be %v, got %v", tc.expectedCursor, pi.Cursor) + } + }) + } +} + func TestListUsers(t *testing.T) { ctx := context.Background() tdb := createDBForTesting(t) diff --git a/frontend/components/CopyToClipboardButton.vue b/frontend/components/CopyToClipboardButton.vue index 5b5d28c..32d32ff 100644 --- a/frontend/components/CopyToClipboardButton.vue +++ b/frontend/components/CopyToClipboardButton.vue @@ -5,7 +5,7 @@ const { t } = useI18n() interface Props { value: string - cta: string + cta?: string | undefined } const props = defineProps() diff --git a/frontend/openapi/generated/pacta/index.ts b/frontend/openapi/generated/pacta/index.ts index ef7d7e1..5db5190 100644 --- a/frontend/openapi/generated/pacta/index.ts +++ b/frontend/openapi/generated/pacta/index.ts @@ -85,5 +85,8 @@ export type { StartPortfolioUploadResp } from './models/StartPortfolioUploadResp export type { StartPortfolioUploadRespItem } from './models/StartPortfolioUploadRespItem'; export type { User } from './models/User'; export type { UserChanges } from './models/UserChanges'; +export type { UserQueryReq } from './models/UserQueryReq'; +export type { UserQueryResp } from './models/UserQueryResp'; +export type { UserQueryWhere } from './models/UserQueryWhere'; export { DefaultService } from './services/DefaultService'; diff --git a/frontend/openapi/generated/pacta/models/UserQueryReq.ts b/frontend/openapi/generated/pacta/models/UserQueryReq.ts new file mode 100644 index 0000000..42aac71 --- /dev/null +++ b/frontend/openapi/generated/pacta/models/UserQueryReq.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { UserQueryWhere } from './UserQueryWhere'; + +export type UserQueryReq = { + /** + * if provided, continues an existing query at the given point + */ + cursor?: string; + /** + * the constraints to place on the returned records + */ + wheres?: Array; +}; + diff --git a/frontend/openapi/generated/pacta/models/UserQueryResp.ts b/frontend/openapi/generated/pacta/models/UserQueryResp.ts new file mode 100644 index 0000000..e31885d --- /dev/null +++ b/frontend/openapi/generated/pacta/models/UserQueryResp.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { User } from './User'; + +export type UserQueryResp = { + users: Array; + /** + * describes whether there are more records to query + */ + hasNextPage: boolean; + /** + * the parameter to re-request with to continue this query on the next page of results + */ + cursor: string; +}; + diff --git a/frontend/openapi/generated/pacta/models/UserQueryWhere.ts b/frontend/openapi/generated/pacta/models/UserQueryWhere.ts new file mode 100644 index 0000000..c16ef84 --- /dev/null +++ b/frontend/openapi/generated/pacta/models/UserQueryWhere.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type UserQueryWhere = { + /** + * if provided, filters the results to only include users whose name or email contains this string + */ + nameOrEmailLike?: string; +}; + diff --git a/frontend/openapi/generated/pacta/services/DefaultService.ts b/frontend/openapi/generated/pacta/services/DefaultService.ts index 35e8ea7..05c2297 100644 --- a/frontend/openapi/generated/pacta/services/DefaultService.ts +++ b/frontend/openapi/generated/pacta/services/DefaultService.ts @@ -43,6 +43,8 @@ import type { StartPortfolioUploadReq } from '../models/StartPortfolioUploadReq' import type { StartPortfolioUploadResp } from '../models/StartPortfolioUploadResp'; import type { User } from '../models/User'; import type { UserChanges } from '../models/UserChanges'; +import type { UserQueryReq } from '../models/UserQueryReq'; +import type { UserQueryResp } from '../models/UserQueryResp'; import type { CancelablePromise } from '../core/CancelablePromise'; import type { BaseHttpRequest } from '../core/BaseHttpRequest'; @@ -810,6 +812,23 @@ export class DefaultService { }); } + /** + * Gets the list of users that the user is able to view, currently an admin-only action + * @param requestBody A request describing which users should be returned + * @returns UserQueryResp + * @throws ApiError + */ + public userQuery( + requestBody: UserQueryReq, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'POST', + url: '/users', + body: requestBody, + mediaType: 'application/json', + }); + } + /** * a callback after login to create or return the user * Creates a user in the database, if the user does not yet exist, or returns the existing user. diff --git a/frontend/pages/admin/index.vue b/frontend/pages/admin/index.vue index 8e537d8..a5d20e8 100644 --- a/frontend/pages/admin/index.vue +++ b/frontend/pages/admin/index.vue @@ -22,11 +22,19 @@ const adminItems: AdminItem[] = [ href: '/admin/initiative', }, { - title: 'Test Portfolio Processing', - icon: 'pi pi-file-o', - desc: 'Test out portfolio processing with an uploaded portfolio', - href: '/admin/portfolio_test', + title: 'User List', + icon: 'pi pi-users', + desc: 'View a list of users, and search for users based on name or email, then edit or delete them', + href: '/admin/users', }, + /* + { + title: 'User Merge', + icon: 'pi pi-user-minus', + desc: 'Merge two user accounts into one, retaining all data', + href: '/admin/user-merge', + }, + */ ] @@ -44,19 +52,14 @@ const adminItems: AdminItem[] = [ > - - + diff --git a/frontend/pages/admin/users.vue b/frontend/pages/admin/users.vue new file mode 100644 index 0000000..16140e3 --- /dev/null +++ b/frontend/pages/admin/users.vue @@ -0,0 +1,155 @@ + + + diff --git a/openapi/pacta.yaml b/openapi/pacta.yaml index f2275a1..a1d8501 100644 --- a/openapi/pacta.yaml +++ b/openapi/pacta.yaml @@ -714,6 +714,23 @@ paths: application/json: schema: $ref: '#/components/schemas/FindUserByMeResp' + /users: + post: + description: Gets the list of users that the user is able to view, currently an admin-only action + operationId: userQuery + requestBody: + description: A request describing which users should be returned + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserQueryReq' + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserQueryResp' /user/authentication-followup: post: description: Creates a user in the database, if the user does not yet exist, or returns the existing user. @@ -2200,6 +2217,40 @@ components: secondaryTargetOwner: type: string description: the id of the owner of the secondary object this action was performed on + UserQueryWhere: + type: object + properties: + nameOrEmailLike: + type: string + description: if provided, filters the results to only include users whose name or email contains this string + UserQueryReq: + type: object + properties: + cursor: + type: string + description: if provided, continues an existing query at the given point + wheres: + type: array + description: the constraints to place on the returned records + items: + $ref: '#/components/schemas/UserQueryWhere' + UserQueryResp: + type: object + required: + - users + - cursor + - hasNextPage + properties: + users: + type: array + items: + $ref: '#/components/schemas/User' + hasNextPage: + type: boolean + description: describes whether there are more records to query + cursor: + type: string + description: the parameter to re-request with to continue this query on the next page of results MergeUsersReq: type: object required: