From f849b487cf88886c08784cdd27d820fc2a592486 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 5 Jul 2024 12:45:31 +0200 Subject: [PATCH] graphql: users query to list users with a few filters --- crates/handlers/src/graphql/query/user.rs | 104 ++++++++++++++- frontend/schema.graphql | 78 +++++++++++ frontend/src/gql/graphql.ts | 46 +++++++ frontend/src/gql/schema.ts | 151 ++++++++++++++++++++++ 4 files changed, 377 insertions(+), 2 deletions(-) diff --git a/crates/handlers/src/graphql/query/user.rs b/crates/handlers/src/graphql/query/user.rs index e216584ca..bf2437f3f 100644 --- a/crates/handlers/src/graphql/query/user.rs +++ b/crates/handlers/src/graphql/query/user.rs @@ -12,10 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -use async_graphql::{Context, Object, ID}; +use async_graphql::{ + connection::{query, Connection, Edge, OpaqueCursor}, + Context, Enum, Object, ID, +}; +use mas_storage::{user::UserFilter, Pagination}; use crate::graphql::{ - model::{NodeType, User}, + model::{Cursor, NodeCursor, NodeType, PreloadedTotalCount, User}, state::ContextExt as _, UserId, }; @@ -72,4 +76,100 @@ impl UserQuery { Ok(Some(User(user))) } + + /// Get a list of users. + /// + /// This is only available to administrators. + async fn users( + &self, + ctx: &Context<'_>, + + #[graphql(name = "state", desc = "List only users with the given state.")] + state_param: Option, + + #[graphql( + name = "canRequestAdmin", + desc = "List only users with the given 'canRequestAdmin' value" + )] + can_request_admin_param: Option, + + #[graphql(desc = "Returns the elements in the list that come after the cursor.")] + after: Option, + #[graphql(desc = "Returns the elements in the list that come before the cursor.")] + before: Option, + #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option, + #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option, + ) -> Result, async_graphql::Error> { + let requester = ctx.requester(); + if !requester.is_admin() { + return Err(async_graphql::Error::new("Unauthorized")); + } + + let state = ctx.state(); + let mut repo = state.repository().await?; + + query( + after, + before, + first, + last, + |after, before, first, last| async move { + let after_id = after + .map(|x: OpaqueCursor| x.extract_for_type(NodeType::User)) + .transpose()?; + let before_id = before + .map(|x: OpaqueCursor| x.extract_for_type(NodeType::User)) + .transpose()?; + let pagination = Pagination::try_new(before_id, after_id, first, last)?; + + // Build the query filter + let filter = UserFilter::new(); + let filter = match can_request_admin_param { + Some(true) => filter.can_request_admin_only(), + Some(false) => filter.cannot_request_admin_only(), + None => filter, + }; + let filter = match state_param { + Some(UserState::Active) => filter.active_only(), + Some(UserState::Locked) => filter.locked_only(), + None => filter, + }; + + let page = repo.user().list(filter, pagination).await?; + + // Preload the total count if requested + let count = if ctx.look_ahead().field("totalCount").exists() { + Some(repo.user().count(filter).await?) + } else { + None + }; + + repo.cancel().await?; + + let mut connection = Connection::with_additional_fields( + page.has_previous_page, + page.has_next_page, + PreloadedTotalCount(count), + ); + connection.edges.extend( + page.edges.into_iter().map(|p| { + Edge::new(OpaqueCursor(NodeCursor(NodeType::User, p.id)), User(p)) + }), + ); + + Ok::<_, async_graphql::Error>(connection) + }, + ) + .await + } +} + +/// The state of a user. +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum UserState { + /// The user is active. + Active, + + /// The user is locked. + Locked, } diff --git a/frontend/schema.graphql b/frontend/schema.graphql index ec536234c..c546e5212 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -999,6 +999,37 @@ type Query { """ userByUsername(username: String!): User """ + Get a list of users. + + This is only available to administrators. + """ + users( + """ + List only users with the given state. + """ + state: UserState + """ + List only users with the given 'canRequestAdmin' value + """ + canRequestAdmin: Boolean + """ + Returns the elements in the list that come after the cursor. + """ + after: String + """ + Returns the elements in the list that come before the cursor. + """ + before: String + """ + Returns the first *n* elements from the list. + """ + first: Int + """ + Returns the last *n* elements from the list. + """ + last: Int + ): UserConnection! + """ Fetch an upstream OAuth 2.0 link by its ID. """ upstreamOauth2Link(id: ID!): UpstreamOAuth2Link @@ -1727,6 +1758,39 @@ type UserAgent { deviceType: DeviceType! } +type UserConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [UserEdge!]! + """ + A list of nodes. + """ + nodes: [User!]! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type UserEdge { + """ + The item at the end of the edge + """ + node: User! + """ + A cursor for use in pagination + """ + cursor: String! +} + """ A user email address """ @@ -1797,6 +1861,20 @@ enum UserEmailState { CONFIRMED } +""" +The state of a user. +""" +enum UserState { + """ + The user is active. + """ + ACTIVE + """ + The user is locked. + """ + LOCKED +} + """ The input for the `verifyEmail` mutation """ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 232c6a10e..66d088109 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -733,6 +733,12 @@ export type Query = { userByUsername?: Maybe; /** Fetch a user email by its ID. */ userEmail?: Maybe; + /** + * Get a list of users. + * + * This is only available to administrators. + */ + users: UserConnection; /** Get the viewer */ viewer: Viewer; /** Get the viewer's session */ @@ -815,6 +821,17 @@ export type QueryUserEmailArgs = { id: Scalars['ID']['input']; }; + +/** The query root of the GraphQL interface. */ +export type QueryUsersArgs = { + after?: InputMaybe; + before?: InputMaybe; + canRequestAdmin?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + state?: InputMaybe; +}; + /** The input for the `removeEmail` mutation */ export type RemoveEmailInput = { /** The ID of the email address to remove */ @@ -1212,6 +1229,27 @@ export type UserAgent = { version?: Maybe; }; +export type UserConnection = { + __typename?: 'UserConnection'; + /** A list of edges. */ + edges: Array; + /** A list of nodes. */ + nodes: Array; + /** Information to aid in pagination. */ + pageInfo: PageInfo; + /** Identifies the total count of items in the connection. */ + totalCount: Scalars['Int']['output']; +}; + +/** An edge in a connection. */ +export type UserEdge = { + __typename?: 'UserEdge'; + /** A cursor for use in pagination */ + cursor: Scalars['String']['output']; + /** The item at the end of the edge */ + node: User; +}; + /** A user email address */ export type UserEmail = CreationEvent & Node & { __typename?: 'UserEmail'; @@ -1257,6 +1295,14 @@ export enum UserEmailState { Pending = 'PENDING' } +/** The state of a user. */ +export enum UserState { + /** The user is active. */ + Active = 'ACTIVE', + /** The user is locked. */ + Locked = 'LOCKED' +} + /** The input for the `verifyEmail` mutation */ export type VerifyEmailInput = { /** The verification code */ diff --git a/frontend/src/gql/schema.ts b/frontend/src/gql/schema.ts index 5d65fdb58..55b7dc9ca 100644 --- a/frontend/src/gql/schema.ts +++ b/frontend/src/gql/schema.ts @@ -2247,6 +2247,61 @@ export default { } ] }, + { + "name": "users", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "UserConnection", + "ofType": null + } + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "Any" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "Any" + } + }, + { + "name": "canRequestAdmin", + "type": { + "kind": "SCALAR", + "name": "Any" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Any" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Any" + } + }, + { + "name": "state", + "type": { + "kind": "SCALAR", + "name": "Any" + } + } + ] + }, { "name": "viewer", "type": { @@ -3382,6 +3437,102 @@ export default { ], "interfaces": [] }, + { + "kind": "OBJECT", + "name": "UserConnection", + "fields": [ + { + "name": "edges", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "UserEdge", + "ofType": null + } + } + } + }, + "args": [] + }, + { + "name": "nodes", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + } + } + }, + "args": [] + }, + { + "name": "pageInfo", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, + "args": [] + }, + { + "name": "totalCount", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Any" + } + }, + "args": [] + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "UserEdge", + "fields": [ + { + "name": "cursor", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Any" + } + }, + "args": [] + }, + { + "name": "node", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + }, + "args": [] + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "UserEmail",