diff --git a/admin-ui/src/gql/types.ts b/admin-ui/src/gql/types.ts index 8c90a5578..419e21283 100644 --- a/admin-ui/src/gql/types.ts +++ b/admin-ui/src/gql/types.ts @@ -1128,6 +1128,7 @@ export type IMutation = { /** * Log the user out of all sessions by invalidating all JWT tokens. * This increments the token version, making all existing tokens invalid. + * Pass a userId to force-logout another user (requires updateUser permission). */ logoutAllSessions?: Maybe; /** Make a proposal as answer to the RFP by changing its status to PROCESSED */ @@ -1627,6 +1628,10 @@ export type IMutationLoginWithWebAuthnArgs = { webAuthnPublicKeyCredentials: Scalars['JSON']['input']; }; +export type IMutationLogoutAllSessionsArgs = { + userId?: InputMaybe; +}; + export type IMutationMakeQuotationProposalArgs = { quotationContext?: InputMaybe; quotationId: Scalars['ID']['input']; @@ -4931,6 +4936,14 @@ export type ILoginWithWebAuthnMutation = { loginWithWebAuthn?: { _id: string; tokenExpires: any } | null; }; +export type ILogoutAllSessionsMutationVariables = Exact<{ + userId?: InputMaybe; +}>; + +export type ILogoutAllSessionsMutation = { + logoutAllSessions?: { success?: boolean | null } | null; +}; + export type IRemoveEmailMutationVariables = Exact<{ email: Scalars['String']['input']; userId?: InputMaybe; @@ -10098,6 +10111,126 @@ export type IRemoveOrderMutationVariables = Exact<{ export type IRemoveOrderMutation = { removeOrder: { _id: string } }; +export type IUserCartQueryVariables = Exact<{ + userId?: InputMaybe; +}>; + +export type IUserCartQuery = { + user?: { + _id: string; + cart?: { + _id: string; + status?: IOrderStatus | null; + created?: any | null; + updated?: any | null; + ordered?: any | null; + orderNumber?: string | null; + confirmed?: any | null; + fulfilled?: any | null; + country?: { + _id: string; + isoCode?: string | null; + flagEmoji?: string | null; + } | null; + items?: Array<{ + _id: string; + quantity: number; + unitPrice?: { amount: number; currencyCode: string } | null; + total?: { amount: number; currencyCode: string } | null; + product: + | { + _id: string; + texts?: { + _id: string; + slug?: string | null; + title?: string | null; + subtitle?: string | null; + } | null; + media: Array<{ + _id: string; + file?: { _id: string; url?: string | null } | null; + }>; + } + | { + _id: string; + texts?: { + _id: string; + slug?: string | null; + title?: string | null; + subtitle?: string | null; + } | null; + media: Array<{ + _id: string; + file?: { _id: string; url?: string | null } | null; + }>; + } + | { + _id: string; + texts?: { + _id: string; + slug?: string | null; + title?: string | null; + subtitle?: string | null; + } | null; + media: Array<{ + _id: string; + file?: { _id: string; url?: string | null } | null; + }>; + } + | { + _id: string; + texts?: { + _id: string; + slug?: string | null; + title?: string | null; + subtitle?: string | null; + } | null; + media: Array<{ + _id: string; + file?: { _id: string; url?: string | null } | null; + }>; + } + | { + _id: string; + texts?: { + _id: string; + slug?: string | null; + title?: string | null; + subtitle?: string | null; + } | null; + media: Array<{ + _id: string; + file?: { _id: string; url?: string | null } | null; + }>; + }; + }> | null; + contact?: { + telNumber?: string | null; + emailAddress?: string | null; + } | null; + total?: { + isTaxable: boolean; + isNetPrice: boolean; + amount: number; + currencyCode: string; + } | null; + user?: { + _id: string; + username?: string | null; + isGuest: boolean; + avatar?: { _id: string; url?: string | null } | null; + profile?: { + displayName?: string | null; + address?: { + firstName?: string | null; + lastName?: string | null; + } | null; + } | null; + } | null; + } | null; + } | null; +}; + export type IUserOrderQueryVariables = Exact<{ userId?: InputMaybe; queryString?: InputMaybe; diff --git a/admin-ui/src/i18n/de.json b/admin-ui/src/i18n/de.json index 25619923d..d60fb0039 100644 --- a/admin-ui/src/i18n/de.json +++ b/admin-ui/src/i18n/de.json @@ -13,6 +13,7 @@ "activate_header_title": "Aktivieren Sie das Abonnement.", "activated": "Aktiviert", "active": "Aktiv", + "active_cart": "Aktiver Warenkorb", "activities": "Aktivitäten", "adapter": "Adapter", "add": "Hinzufügen", @@ -108,6 +109,8 @@ "cancel": "Abbrechen", "cart": "Warenkorb", "cart_created": "Warenkorb erstellt", + "cart_empty": "Warenkorb ist leer", + "cart_last_updated": "Zuletzt aktualisiert {date}", "categories_count": "Kategorien", "chain-id": "Chain-ID", "chain-token-id": "Chain-Token-ID", @@ -389,6 +392,9 @@ "filters": "Filter", "finished": "Beendet", "first_name": "Vorname", + "force_logout": "Abmeldung erzwingen", + "force_logout_confirmation": "Alle aktiven Sitzungen dieses Benutzers werden ungültig und der Benutzer muss sich erneut anmelden. Fortfahren?", + "force_logout_success": "Alle Sitzungen des Benutzers wurden beendet", "forget_password": "Passwort vergessen?", "forgot_password_header_description": "Geben Sie unten Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts!", "from": "Von", @@ -476,6 +482,10 @@ "log_out": "Abmelden", "login_date_range": "Zuletzt angemeldet", "login_header": "Anmelden zu Ihrem Konto", + "logout_all_sessions_admin_description": "Alle aktiven Sitzungen dieses Benutzers ungültig machen, zum Beispiel wenn das Konto kompromittiert sein könnte.", + "logout_all_sessions_confirmation": "Sie werden auf diesem und allen anderen Geräten abgemeldet. Fortfahren?", + "logout_all_sessions_own_description": "Von allen Sitzungen auf allen Geräten abmelden, einschließlich dieser.", + "logout_everywhere": "Überall abmelden", "make-proposal-header": "Angebotserstellung", "make_leaf": "Zu Blatt machen", "make_root": "Zu Wurzel machen", @@ -724,6 +734,7 @@ "send_reset_link": "Passwort-Zurücksetzungslink senden", "send_verification_mail": "Send verification mail", "sequence": "Anzeigereihenfolge", + "sessions": "Sitzungen", "set_password": "Passwort festlegen", "setup_complete": "Abgeschlossen", "setup_now": "Jetzt einrichten", @@ -960,6 +971,7 @@ "variation.id": "ID", "variation.untitled": "Unbetitelte Variation", "view": "Sicht", + "view_cart": "Warenkorb anzeigen", "bundle_products": "Pakete", "chat.error.auth": "🔐 **Authentifizierungsfehler** Ihre Sitzung ist abgelaufen oder das Authentifizierungstoken ist ungültig. *Bitte aktualisieren Sie die Seite oder melden Sie sich erneut an.*", "chat.error.connection": "🚫 **Verbindungsfehler** Verbindung zum Chat-API-Server unter {chatUrl} nicht möglich. **So aktivieren Sie den Chat:** 1. Starten Sie das Backend. 2. Stellen Sie sicher, dass der Chat-/MCP-Endpunkt konfiguriert ist. 3. Überprüfen Sie NEXT_PUBLIC_CHAT_URL. **Aktuelle Konfiguration:** - Chat-URL: {chatUrl} *Bitte starten Sie das Backend.*", diff --git a/admin-ui/src/i18n/en.json b/admin-ui/src/i18n/en.json index fc13ebf64..93b591a6d 100644 --- a/admin-ui/src/i18n/en.json +++ b/admin-ui/src/i18n/en.json @@ -13,6 +13,7 @@ "activate_header_title": "Activate subscription.", "activated": "Activated", "active": "Active", + "active_cart": "Active cart", "active_types": "Active Worker Types ({count})", "activities": "Activities", "adapter": "Adapter", @@ -128,7 +129,9 @@ "bundled_items": "Bundled items", "cancel": "Cancel", "cart": "Cart", - "cart_created": "Warenkorb erstellt", + "cart_created": "Cart created", + "cart_empty": "Cart is empty", + "cart_last_updated": "Last updated {date}", "categories_count": "Assortments", "chain-id": "Chain ID {chainId}", "chain-token-id": "Token Serial Number", @@ -454,6 +457,9 @@ "filters_csv_file": "Filters CSV", "finished": "Finished", "first_name": "First name", + "force_logout": "Force logout", + "force_logout_confirmation": "All active sessions of this user will be invalidated and the user will have to sign in again. Continue?", + "force_logout_success": "All sessions of the user have been terminated", "forget_password": "Forgot your password?", "forgot_password_header_description": "Enter your email address below and we'll send you a link to reset your password!", "from": "From", @@ -542,6 +548,10 @@ "log_out": "Log out", "login_date_range": "Login Date Range", "login_header": "Log in to your account", + "logout_all_sessions_admin_description": "Invalidate all active sessions of this user, for example if the account might be compromised.", + "logout_all_sessions_confirmation": "You will be signed out on this and every other device. Continue?", + "logout_all_sessions_own_description": "Sign out of all sessions on every device, including this one.", + "logout_everywhere": "Log out everywhere", "make-proposal-header": "Make quotation proposal", "make_leaf": "Make leaf", "make_root": "Make root", @@ -843,6 +853,7 @@ "send_reset_link": "Send reset link", "send_verification_mail": "Send verification mail", "sequence": "Display Order", + "sessions": "Sessions", "set_password": "Set password", "setup_complete": "Complete", "setup_now": "Setup Now", @@ -1009,6 +1020,7 @@ "verify_email_token_invalid_error": "The email verification token is either invalid or expired.", "version": "Version", "view": "View", + "view_cart": "View cart", "wallet-address": "Wallet {walletAddress}", "warehouse": "Warehouse", "warehouse_detail_title": "Warehouse {id}", diff --git a/admin-ui/src/modules/accounts/components/AccountView.tsx b/admin-ui/src/modules/accounts/components/AccountView.tsx index 70fb68c90..0d44ff49c 100644 --- a/admin-ui/src/modules/accounts/components/AccountView.tsx +++ b/admin-ui/src/modules/accounts/components/AccountView.tsx @@ -11,6 +11,7 @@ import UserRolesView from './UserRolesView'; import UserTagsView from './UserTagsView'; import UserWebAuthCredentials from './UserWebAuthCredentials'; import Web3Addresses from './Web3Addresses'; +import SessionsView from './SessionsView'; const AccountView = ({ _id, @@ -157,6 +158,19 @@ const AccountView = ({ )} + + {(currentUser?._id === _id || hasRole(IRoleAction.UpdateUser)) && ( + +
+ +
+
+ )} ); }; diff --git a/admin-ui/src/modules/accounts/components/SessionsView.tsx b/admin-ui/src/modules/accounts/components/SessionsView.tsx new file mode 100644 index 000000000..dd780b3c0 --- /dev/null +++ b/admin-ui/src/modules/accounts/components/SessionsView.tsx @@ -0,0 +1,111 @@ +import { useIntl } from 'react-intl'; +import { toast } from 'react-toastify'; +import { useRouter } from 'next/router'; +import { useApolloClient } from '@apollo/client/react'; +import { ArrowRightStartOnRectangleIcon } from '@heroicons/react/20/solid'; + +import Button from '@/components/ui/Button'; +import useModal from '../../modal/hooks/useModal'; +import DangerMessage from '../../modal/components/DangerMessage'; +import useLogoutAllSessions from '../hooks/useLogoutAllSessions'; +import useCurrentUser from '../hooks/useCurrentUser'; + +const SessionsView = ({ userId }) => { + const { formatMessage } = useIntl(); + const { currentUser } = useCurrentUser(); + const { logoutAllSessions } = useLogoutAllSessions(); + const { setModal } = useModal(); + const apollo = useApolloClient(); + const router = useRouter(); + const isOwnUser = currentUser?._id === userId; + + const onLogoutOwnSessions = async () => { + await setModal( + setModal('')} + message={formatMessage({ + id: 'logout_all_sessions_confirmation', + defaultMessage: + 'You will be signed out on this and every other device. Continue?', + })} + onOkClick={async () => { + setModal(''); + await logoutAllSessions(); + try { + await apollo.resetStore(); + } catch (e) { + if ((e as any)?.name !== 'AbortError') throw e; + } + router.push('/log-in'); + }} + okText={formatMessage({ + id: 'logout_everywhere', + defaultMessage: 'Log out everywhere', + })} + />, + ); + }; + + const onForceLogoutUser = async () => { + await setModal( + setModal('')} + message={formatMessage({ + id: 'force_logout_confirmation', + defaultMessage: + 'All active sessions of this user will be invalidated and the user will have to sign in again. Continue?', + })} + onOkClick={async () => { + setModal(''); + await logoutAllSessions({ userId }); + toast.success( + formatMessage({ + id: 'force_logout_success', + defaultMessage: 'All sessions of the user have been terminated', + }), + ); + }} + okText={formatMessage({ + id: 'force_logout', + defaultMessage: 'Force logout', + })} + />, + ); + }; + + return ( +
+

+ {isOwnUser + ? formatMessage({ + id: 'logout_all_sessions_own_description', + defaultMessage: + 'Sign out of all sessions on every device, including this one.', + }) + : formatMessage({ + id: 'logout_all_sessions_admin_description', + defaultMessage: + 'Invalidate all active sessions of this user, for example if the account might be compromised.', + })} +

+
+ ); +}; + +export default SessionsView; diff --git a/admin-ui/src/modules/accounts/components/UserCart.tsx b/admin-ui/src/modules/accounts/components/UserCart.tsx new file mode 100644 index 000000000..4657157cd --- /dev/null +++ b/admin-ui/src/modules/accounts/components/UserCart.tsx @@ -0,0 +1,123 @@ +import Link from 'next/link'; +import { useIntl } from 'react-intl'; +import { ShoppingCartIcon } from '@heroicons/react/24/outline'; + +import Loading from '@/components/ui/Loading'; +import NoData from '@/components/ui/NoData'; +import ImageWithFallback from '@/components/ui/ImageWithFallback'; +import { IRoleAction } from '../../../gql/types'; +import useAuth from '../../Auth/useAuth'; +import useUserCart from '../../order/hooks/useUserCart'; +import useFormatDateTime from '../../common/utils/useFormatDateTime'; +import { useFormatPrice } from '../../common/utils/utils'; +import generateUniqueId from '../../common/utils/getUniqueId'; + +const UserCart = ({ _id: userId }) => { + const { formatMessage } = useIntl(); + const { formatDateTime } = useFormatDateTime(); + const { formatPrice } = useFormatPrice(); + const { hasRole } = useAuth(); + const { cart, loading } = useUserCart({ userId }); + + if (loading) return ; + + if (!cart) + return ( +
+ } + /> +
+ ); + + return ( +
+
+
+
+ + + {formatMessage( + { + id: 'cart_last_updated', + defaultMessage: 'Last updated {date}', + }, + { + date: formatDateTime(cart.updated || cart.created, { + dateStyle: 'medium', + timeStyle: 'short', + }), + }, + )} + + {cart.country?.isoCode && ( + + {cart.country?.flagEmoji} {cart.country.isoCode} + + )} +
+
+ + {formatPrice(cart.total)} + + {hasRole(IRoleAction.ViewOrders) && ( + + {formatMessage({ + id: 'view_cart', + defaultMessage: 'View cart', + })} + + )} +
+
+
    + {cart.items?.map((item) => ( +
  • +
    + +
    +
    + + {item.product?.texts?.title || + item.product?.texts?.subtitle || + item.product?._id} + + + {item.quantity} × {formatPrice(item.unitPrice)} + +
    + + {formatPrice(item.total)} + +
  • + ))} + {!cart.items?.length && ( +
  • + {formatMessage({ + id: 'cart_empty', + defaultMessage: 'Cart is empty', + })} +
  • + )} +
+
+
+ ); +}; + +export default UserCart; diff --git a/admin-ui/src/modules/accounts/components/UserSettings.tsx b/admin-ui/src/modules/accounts/components/UserSettings.tsx index 34d368be6..e948a7288 100644 --- a/admin-ui/src/modules/accounts/components/UserSettings.tsx +++ b/admin-ui/src/modules/accounts/components/UserSettings.tsx @@ -5,6 +5,7 @@ import { LockClosedIcon, StarIcon, PuzzlePieceIcon, + ShoppingBagIcon, ShoppingCartIcon, UserIcon, CurrencyDollarIcon, @@ -23,6 +24,7 @@ import UserProductReviews from './UserProductReviews'; import AccountView from './AccountView'; import PaymentCredentialsView from './PaymentCredentialsView'; import ProfileView from './ProfileView'; +import UserCart from './UserCart'; import UserEnrollments from './UserEnrollments'; import UserOrders from './UserOrders'; import UserQuotations from './UserQuotations'; @@ -35,6 +37,7 @@ const GetCurrentTab = ({ user, selectedView, ...extendedData }) => { if (selectedView === 'profile') return ; if (selectedView === 'account') return ; if (selectedView === 'orders') return ; + if (selectedView === 'cart') return ; if (selectedView === 'quotations') return ; if (selectedView === 'enrollments') return ; if (selectedView === 'reviews') return ; @@ -83,6 +86,11 @@ const UserSettings = ({ user, extendedData }) => { (isOwnUser || hasRole(IRoleAction.ViewUserOrders)) && { id: 'orders', title: formatMessage({ id: 'orders', defaultMessage: 'Orders' }), + Icon: , + }, + (isOwnUser || hasRole(IRoleAction.ViewUserOrders)) && { + id: 'cart', + title: formatMessage({ id: 'cart', defaultMessage: 'Cart' }), Icon: , }, hasRole(IRoleAction.ViewUserQuotations) && { diff --git a/admin-ui/src/modules/accounts/hooks/useLogoutAllSessions.ts b/admin-ui/src/modules/accounts/hooks/useLogoutAllSessions.ts new file mode 100644 index 000000000..e660939bd --- /dev/null +++ b/admin-ui/src/modules/accounts/hooks/useLogoutAllSessions.ts @@ -0,0 +1,27 @@ +import { gql } from '@apollo/client'; +import { useMutation } from '@apollo/client/react'; + +const LogoutAllSessionsMutation = gql` + mutation LogoutAllSessions($userId: ID) { + logoutAllSessions(userId: $userId) { + success + } + } +`; + +const useLogoutAllSessions = () => { + const [logoutAllSessionsMutation] = useMutation< + { logoutAllSessions: { success: boolean } }, + { userId?: string } + >(LogoutAllSessionsMutation); + + const logoutAllSessions = async ({ userId }: { userId?: string } = {}) => { + return logoutAllSessionsMutation({ variables: { userId } }); + }; + + return { + logoutAllSessions, + }; +}; + +export default useLogoutAllSessions; diff --git a/admin-ui/src/modules/order/hooks/useUserCart.ts b/admin-ui/src/modules/order/hooks/useUserCart.ts new file mode 100644 index 000000000..c26e8e523 --- /dev/null +++ b/admin-ui/src/modules/order/hooks/useUserCart.ts @@ -0,0 +1,67 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; +import OrderFragment from '../fragments/OrderFragment'; +import { IUserCartQuery, IUserCartQueryVariables } from '@/gql/types'; + +const UserCartQuery = gql` + query UserCart($userId: ID) { + user(userId: $userId) { + _id + cart { + ...OrderFragment + country { + _id + isoCode + flagEmoji + } + items { + _id + quantity + unitPrice { + amount + currencyCode + } + total { + amount + currencyCode + } + product { + _id + texts { + _id + slug + title + subtitle + } + media(limit: 1) { + _id + file { + _id + url + } + } + } + } + } + } + } + ${OrderFragment} +`; + +const useUserCart = ({ userId = null }: { userId?: string } = {}) => { + const { data, loading, error } = useQuery< + IUserCartQuery, + IUserCartQueryVariables + >(UserCartQuery, { + skip: !userId, + variables: { userId }, + }); + + return { + cart: data?.user?.cart, + loading, + error, + }; +}; + +export default useUserCart; diff --git a/packages/api/src/resolvers/mutations/accounts/logoutAllSessions.ts b/packages/api/src/resolvers/mutations/accounts/logoutAllSessions.ts index 5fc92d769..e106f0294 100644 --- a/packages/api/src/resolvers/mutations/accounts/logoutAllSessions.ts +++ b/packages/api/src/resolvers/mutations/accounts/logoutAllSessions.ts @@ -1,21 +1,32 @@ import { log } from '@unchainedshop/logger'; import type { Context } from '../../../context.ts'; +import { UserNotFoundError } from '../../../errors.ts'; -export default async function logoutAllSessions(root: never, _: never, context: Context) { +export default async function logoutAllSessions( + root: never, + params: { userId?: string }, + context: Context, +) { const { userId, modules } = context; + const normalizedUserId = params.userId || userId; - log('mutation logoutAllSessions', { userId }); + log(`mutation logoutAllSessions ${normalizedUserId}`, { userId }); + + if (!(await modules.users.userExists({ userId: normalizedUserId! }))) + throw new UserNotFoundError({ userId: normalizedUserId }); // Increment the user's tokenVersion to invalidate all existing JWT tokens - // userId is guaranteed to be defined by ACL (loggedIn role) - const result = await modules.users.incrementTokenVersion(userId!); + const result = await modules.users.incrementTokenVersion(normalizedUserId!); if (!result) { throw new Error('Failed to logout all sessions', { cause: 'LOGOUT_FAILED' }); } - // Also logout the current session (clear the cookie) - await context.logout(); + // Only clear the current session cookie when logging out ourselves, + // an admin force-logging-out another user keeps their own session + if (normalizedUserId === userId) { + await context.logout(); + } return { success: true }; } diff --git a/packages/api/src/roles/loggedIn.ts b/packages/api/src/roles/loggedIn.ts index d7c7a0d39..3e3bbc17b 100644 --- a/packages/api/src/roles/loggedIn.ts +++ b/packages/api/src/roles/loggedIn.ts @@ -275,5 +275,5 @@ export const loggedIn = (role: any, actions: Record) => { role.allow(actions.uploadUserAvatar, canUpdateAvatar); role.allow(actions.uploadTempFile, canUpdateAvatar); role.allow(actions.changePassword, () => true); - role.allow(actions.logoutAllSessions, () => true); + role.allow(actions.logoutAllSessions, isMyself); }; diff --git a/packages/api/src/schema/mutation.ts b/packages/api/src/schema/mutation.ts index d5305ec40..8c1f34c46 100644 --- a/packages/api/src/schema/mutation.ts +++ b/packages/api/src/schema/mutation.ts @@ -60,8 +60,9 @@ export default [ """ Log the user out of all sessions by invalidating all JWT tokens. This increments the token version, making all existing tokens invalid. + Pass a userId to force-logout another user (requires updateUser permission). """ - logoutAllSessions: SuccessResponse + logoutAllSessions(userId: ID): SuccessResponse """ Impersonate a user diff --git a/tests/auth-logout.test.js b/tests/auth-logout.test.js index a097158d5..1243be931 100644 --- a/tests/auth-logout.test.js +++ b/tests/auth-logout.test.js @@ -5,7 +5,7 @@ import { disconnect, } from './helpers.js'; import { getServerPort, getOidcPrivateKey, TEST_OIDC_ISSUER, TEST_OIDC_AUDIENCE } from './setup.js'; -import { User, USER_TOKEN } from './seeds/users.js'; +import { Admin, User, ADMIN_TOKEN, USER_TOKEN } from './seeds/users.js'; import assert from 'node:assert'; import test from 'node:test'; import * as jose from 'jose'; @@ -59,6 +59,88 @@ test.describe('Auth: logoutAllSessions Mutation', () => { assert.strictEqual(userAfter.tokenVersion, tokenVersionBefore + 1); }); + test('user can pass their own userId explicitly', async () => { + const { data: { logoutAllSessions } = {} } = await graphqlFetch({ + query: /* GraphQL */ ` + mutation LogoutAllSessions($userId: ID) { + logoutAllSessions(userId: $userId) { + success + } + } + `, + variables: { userId: User._id }, + }); + + assert.strictEqual(logoutAllSessions.success, true); + }); + + test('regular user cannot force-logout another user', async () => { + const { errors } = await graphqlFetch({ + query: /* GraphQL */ ` + mutation LogoutAllSessions($userId: ID) { + logoutAllSessions(userId: $userId) { + success + } + } + `, + variables: { userId: Admin._id }, + }); + + assert.strictEqual(errors[0]?.extensions?.code, 'NoPermissionError'); + }); + + test('admin can force-logout another user without ending own session', async () => { + const adminFetch = createLoggedInGraphqlFetch(ADMIN_TOKEN); + const Users = db.collection('users'); + + const userBefore = await Users.findOne({ _id: User._id }); + const tokenVersionBefore = userBefore?.tokenVersion ?? 0; + + const { data: { logoutAllSessions } = {} } = await adminFetch({ + query: /* GraphQL */ ` + mutation LogoutAllSessions($userId: ID) { + logoutAllSessions(userId: $userId) { + success + } + } + `, + variables: { userId: User._id }, + }); + + assert.strictEqual(logoutAllSessions.success, true); + + const userAfter = await Users.findOne({ _id: User._id }); + assert.strictEqual(userAfter.tokenVersion, tokenVersionBefore + 1); + + // admin session remains usable + const { data: { me } = {} } = await adminFetch({ + query: /* GraphQL */ ` + query { + me { + _id + } + } + `, + }); + assert.strictEqual(me._id, Admin._id); + }); + + test('admin force-logout of unknown user returns UserNotFoundError', async () => { + const adminFetch = createLoggedInGraphqlFetch(ADMIN_TOKEN); + const { errors } = await adminFetch({ + query: /* GraphQL */ ` + mutation LogoutAllSessions($userId: ID) { + logoutAllSessions(userId: $userId) { + success + } + } + `, + variables: { userId: 'does-not-exist' }, + }); + + assert.strictEqual(errors[0]?.extensions?.code, 'UserNotFoundError'); + }); + test('anonymous user cannot call logoutAllSessions', async () => { const anonymousFetch = createAnonymousGraphqlFetch(); const { errors } = await anonymousFetch({