From 98876f23b8589c34310aaaabed01190552cf51e3 Mon Sep 17 00:00:00 2001 From: Pascal Martineau Date: Mon, 15 Sep 2025 12:34:59 -0400 Subject: [PATCH] feat: Initial user switching mutations --- .../ccat/app/composables/useUserSwitching.ts | 38 +++++++ wp-content/themes/ccat/functions.php | 3 + .../ccat/includes/graphql/user-switching.php | 103 ++++++++++++++++++ .../ccat/server/api/switch-back.post.ts | 19 ++++ .../themes/ccat/server/api/switch-to.post.ts | 33 ++++++ .../themes/ccat/server/graphql/schema.graphql | 59 ++++++++++ .../ccat/server/graphql/userSwitchBack.gql | 5 + .../ccat/server/graphql/userSwitchTo.gql | 17 +++ 8 files changed, 277 insertions(+) create mode 100644 wp-content/themes/ccat/app/composables/useUserSwitching.ts create mode 100644 wp-content/themes/ccat/includes/graphql/user-switching.php create mode 100644 wp-content/themes/ccat/server/api/switch-back.post.ts create mode 100644 wp-content/themes/ccat/server/api/switch-to.post.ts create mode 100644 wp-content/themes/ccat/server/graphql/userSwitchBack.gql create mode 100644 wp-content/themes/ccat/server/graphql/userSwitchTo.gql diff --git a/wp-content/themes/ccat/app/composables/useUserSwitching.ts b/wp-content/themes/ccat/app/composables/useUserSwitching.ts new file mode 100644 index 0000000..e294f68 --- /dev/null +++ b/wp-content/themes/ccat/app/composables/useUserSwitching.ts @@ -0,0 +1,38 @@ +export const useUserSwitching = () => { + const session = useUserSession(); + + const isSwitched = computed(() => Boolean(session.data.value?.switchedBy)); + + const switchTo = async (userId: string | number) => { + const response = await $fetch("/api/switch-to", { + method: "POST", + body: { userId }, + }); + + if (response.success) { + await session.fetch(); + return response; + } + + throw new Error(response.message || "Switch failed"); + }; + + const switchBack = async () => { + const response = await $fetch("/api/switch-back", { + method: "POST", + }); + + if (response.success) { + await session.fetch(); + return response; + } + + throw new Error(response.message || "Switch back failed"); + }; + + return { + isSwitched, + switchTo, + switchBack, + }; +}; diff --git a/wp-content/themes/ccat/functions.php b/wp-content/themes/ccat/functions.php index 3600a58..df0e260 100644 --- a/wp-content/themes/ccat/functions.php +++ b/wp-content/themes/ccat/functions.php @@ -22,6 +22,9 @@ require_once __DIR__ . '/includes/taxonomies/resource-category.php'; // Forms +// GraphQL +require_once __DIR__ . '/includes/graphql/user-switching.php'; + // Roles // Sections diff --git a/wp-content/themes/ccat/includes/graphql/user-switching.php b/wp-content/themes/ccat/includes/graphql/user-switching.php new file mode 100644 index 0000000..0376416 --- /dev/null +++ b/wp-content/themes/ccat/includes/graphql/user-switching.php @@ -0,0 +1,103 @@ + array( + 'userId' => array( + 'type' => 'ID', + 'description' => esc_html__( 'The ID of the user to switch to', 'ccat' ), + ), + ), + 'outputFields' => array( + 'authToken' => array( + 'type' => 'String', + 'description' => esc_html__( 'JWT Token for the target user', 'ccat' ), + ), + 'refreshToken' => array( + 'type' => 'String', + 'description' => esc_html__( 'JWT Refresh Token for the target user', 'ccat' ), + ), + 'user' => array( + 'type' => 'User', + 'description' => esc_html__( 'The target user object', 'ccat' ), + ), + ), + 'mutateAndGetPayload' => 'ccat_graphql_switch_to_mutation', + ) + ); +} + +// Callback for userSwitchTo mutation +function ccat_graphql_switch_to_mutation( $input ) { + if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) { + throw new \GraphQL\Error\UserError( esc_html__( 'Insufficient permissions', 'ccat' ) ); + } + $user_id = absint( $input['userId'] ); + $current_user_id = get_current_user_id(); + if ( $user_id === $current_user_id ) { + throw new \GraphQL\Error\UserError( esc_html__( 'Cannot switch to yourself', 'ccat' ) ); + } + $target_user = get_user_by( 'ID', $user_id ); + if ( ! $target_user ) { + throw new \GraphQL\Error\UserError( esc_html__( 'User not found', 'ccat' ) ); + } + $secret_key = defined( 'GRAPHQL_JWT_AUTH_SECRET_KEY' ) ? GRAPHQL_JWT_AUTH_SECRET_KEY : wp_salt(); + $issued_at = time(); + $expire = $issued_at + ( DAY_IN_SECONDS * 7 ); + $token_data = array( + 'iss' => get_bloginfo( 'url' ), + 'iat' => $issued_at, + 'nbf' => $issued_at, + 'exp' => $expire, + 'data' => array( + 'user' => array( + 'id' => $target_user->ID, + ), + 'switched_by' => $current_user_id, + ), + ); + $auth_token = \Firebase\JWT\JWT::encode( $token_data, $secret_key, 'HS256' ); + $refresh_token_data = array( + 'iss' => get_bloginfo( 'url' ), + 'iat' => $issued_at, + 'nbf' => $issued_at, + 'exp' => $issued_at + ( DAY_IN_SECONDS * 30 ), + 'data' => array( + 'user' => array( 'id' => $target_user->ID ), + 'switched_by' => $current_user_id, + ), + ); + $refresh_token = \Firebase\JWT\JWT::encode( $refresh_token_data, $secret_key, 'HS256' ); + return array( + 'authToken' => $auth_token, + 'refreshToken' => $refresh_token, + 'user' => \WPGraphQL::get_app_context()->get_loader( 'user' )->load_deferred( $target_user->ID ), + ); +} + +// Register userSwitchBack mutation +add_action( 'graphql_register_types', 'ccat_graphql_register_user_switch_back' ); +function ccat_graphql_register_user_switch_back() { + register_graphql_mutation( + 'userSwitchBack', + array( + 'inputFields' => array(), + 'outputFields' => array( + 'success' => array( + 'type' => 'Boolean', + 'description' => esc_html__( 'Whether switching back was successful', 'ccat' ), + ), + ), + 'mutateAndGetPayload' => 'ccat_graphql_switch_back_mutation', + ) + ); +} + +// Callback for userSwitchBack mutation +function ccat_graphql_switch_back_mutation() { + return array( 'success' => true ); +} diff --git a/wp-content/themes/ccat/server/api/switch-back.post.ts b/wp-content/themes/ccat/server/api/switch-back.post.ts new file mode 100644 index 0000000..5efcc06 --- /dev/null +++ b/wp-content/themes/ccat/server/api/switch-back.post.ts @@ -0,0 +1,19 @@ +import { defineEventHandler } from "h3"; + +export default defineEventHandler(async (event) => { + try { + const response = await useGraphqlMutation("userSwitchBack"); + + if (response.errors?.length) { + throw new Error(response.errors[0]?.message || "Switch back failed"); + } + + await clearUserSession(event); + + return { success: true }; + } + catch (error) { + const message = error instanceof Error ? error.message : "Switch back failed"; + return { success: false, message }; + } +}); diff --git a/wp-content/themes/ccat/server/api/switch-to.post.ts b/wp-content/themes/ccat/server/api/switch-to.post.ts new file mode 100644 index 0000000..bc500fe --- /dev/null +++ b/wp-content/themes/ccat/server/api/switch-to.post.ts @@ -0,0 +1,33 @@ +import { defineEventHandler, readBody } from "h3"; + +export default defineEventHandler(async (event) => { + const { userId } = await readBody(event); + + try { + const currentSession = await getUserSession(event); + if (!currentSession?.user) { + throw new Error("Authentication required"); + } + + const response = await useGraphqlMutation("userSwitchTo", { userId }); + + if (response.errors?.length) { + throw new Error(response.errors[0]?.message || "Switch failed"); + } + + const { authToken, refreshToken, user } = response.data.userSwitchTo; + + await setUserSession(event, { + user, + secure: { authToken, refreshToken }, + loggedInAt: new Date().toISOString(), + switchedBy: currentSession.user.id, + }); + + return { success: true }; + } + catch (error) { + const message = error instanceof Error ? error.message : "Switch failed"; + return { success: false, message }; + } +}); \ No newline at end of file diff --git a/wp-content/themes/ccat/server/graphql/schema.graphql b/wp-content/themes/ccat/server/graphql/schema.graphql index 4b0db94..83373d6 100644 --- a/wp-content/themes/ccat/server/graphql/schema.graphql +++ b/wp-content/themes/ccat/server/graphql/schema.graphql @@ -17146,6 +17146,18 @@ type RootMutation { """Input for the updateUser mutation""" input: UpdateUserInput! ): UpdateUserPayload + + """The userSwitchBack mutation""" + userSwitchBack( + """Input for the userSwitchBack mutation""" + input: UserSwitchBackInput! + ): UserSwitchBackPayload + + """The userSwitchTo mutation""" + userSwitchTo( + """Input for the userSwitchTo mutation""" + input: UserSwitchToInput! + ): UserSwitchToPayload } """The root entry point into the Graph""" @@ -24625,6 +24637,53 @@ enum UserRoleEnum { SUBSCRIBER } +"""Input for the userSwitchBack mutation.""" +input UserSwitchBackInput { + """ + This is an ID that can be passed to a mutation by the client to track the progress of mutations and catch possible duplicate mutation submissions. + """ + clientMutationId: String +} + +"""The payload for the userSwitchBack mutation.""" +type UserSwitchBackPayload { + """ + If a 'clientMutationId' input is provided to the mutation, it will be returned as output on the mutation. This ID can be used by the client to track the progress of mutations and catch possible duplicate mutation submissions. + """ + clientMutationId: String + + """Whether switching back was successful""" + success: Boolean +} + +"""Input for the userSwitchTo mutation.""" +input UserSwitchToInput { + """ + This is an ID that can be passed to a mutation by the client to track the progress of mutations and catch possible duplicate mutation submissions. + """ + clientMutationId: String + + """The ID of the user to switch to""" + userId: ID +} + +"""The payload for the userSwitchTo mutation.""" +type UserSwitchToPayload { + """JWT Token for the target user""" + authToken: String + + """ + If a 'clientMutationId' input is provided to the mutation, it will be returned as output on the mutation. This ID can be used by the client to track the progress of mutations and catch possible duplicate mutation submissions. + """ + clientMutationId: String + + """JWT Refresh Token for the target user""" + refreshToken: String + + """The target user object""" + user: User +} + """Connection between the User type and the Comment type""" type UserToCommentConnection implements CommentConnection & Connection { """Edges for the UserToCommentConnection connection""" diff --git a/wp-content/themes/ccat/server/graphql/userSwitchBack.gql b/wp-content/themes/ccat/server/graphql/userSwitchBack.gql new file mode 100644 index 0000000..b5c4e58 --- /dev/null +++ b/wp-content/themes/ccat/server/graphql/userSwitchBack.gql @@ -0,0 +1,5 @@ +mutation userSwitchBack { + userSwitchBack(input: {}) { + success + } +} \ No newline at end of file diff --git a/wp-content/themes/ccat/server/graphql/userSwitchTo.gql b/wp-content/themes/ccat/server/graphql/userSwitchTo.gql new file mode 100644 index 0000000..d5bd436 --- /dev/null +++ b/wp-content/themes/ccat/server/graphql/userSwitchTo.gql @@ -0,0 +1,17 @@ +mutation userSwitchTo($userId: ID!) { + userSwitchTo(input: { userId: $userId }) { + authToken + refreshToken + user { + id + databaseId + username + email + firstName + lastName + avatar { + url + } + } + } +} \ No newline at end of file