diff --git a/packages/common/src/services/audius-backend/AudiusBackend.ts b/packages/common/src/services/audius-backend/AudiusBackend.ts index 60e5ce122bb..c4cd5b6ebf0 100644 --- a/packages/common/src/services/audius-backend/AudiusBackend.ts +++ b/packages/common/src/services/audius-backend/AudiusBackend.ts @@ -647,6 +647,10 @@ export const audiusBackend = ({ FeatureFlags.SDK_DISCOVERY_NODE_SELECTOR ) + const isManagerModeEnabled = await getFeatureEnabled( + FeatureFlags.MANAGER_MODE + ) + let discoveryNodeSelector: Maybe if (useSdkDiscoveryNodeSelector) { @@ -710,7 +714,8 @@ export const audiusBackend = ({ unhealthyBlockDiff: getRemoteVar(IntKeys.DISCOVERY_NODE_MAX_BLOCK_DIFF) ?? undefined, - discoveryNodeSelector + discoveryNodeSelector, + enableUserIdOverride: isManagerModeEnabled }, identityServiceConfig: AudiusLibs.configIdentityService(identityServiceUrl), diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index 77e61301487..64f09cd7944 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -61,7 +61,8 @@ export enum FeatureFlags { SDK_MIGRATION_SHADOWING = 'sdk_migration_shadowing', USE_SDK_TIPS = 'use_sdk_tips', USE_SDK_REWARDS = 'use_sdk_rewards', - DISCOVERY_TIP_REACTIONS = 'discovery_tip_reactions' + DISCOVERY_TIP_REACTIONS = 'discovery_tip_reactions', + MANAGER_MODE = 'manager_mode' } type FlagDefaults = Record @@ -138,5 +139,6 @@ export const flagDefaults: FlagDefaults = { [FeatureFlags.SDK_MIGRATION_SHADOWING]: false, [FeatureFlags.USE_SDK_TIPS]: false, [FeatureFlags.USE_SDK_REWARDS]: false, - [FeatureFlags.DISCOVERY_TIP_REACTIONS]: false + [FeatureFlags.DISCOVERY_TIP_REACTIONS]: false, + [FeatureFlags.MANAGER_MODE]: false } diff --git a/packages/libs/src/sdk/api/grants/GrantsApi.ts b/packages/libs/src/sdk/api/grants/GrantsApi.ts index a03f539dc01..eb1fc08075e 100644 --- a/packages/libs/src/sdk/api/grants/GrantsApi.ts +++ b/packages/libs/src/sdk/api/grants/GrantsApi.ts @@ -14,7 +14,8 @@ import { CreateGrantRequest, CreateGrantSchema, RevokeGrantRequest, - RevokeGrantSchema + RevokeGrantSchema, + RemoveManagerRequest } from './types' export class GrantsApi { @@ -85,6 +86,42 @@ export class GrantsApi { }) } + /** + * Revokes a user's manager access - can either be called by the manager user or the child user + */ + async removeManager(params: RemoveManagerRequest) { + const { userId, managerUserId } = await parseParams( + 'addManager', + AddManagerSchema + )(params) + let managerUser: User | undefined + try { + managerUser = ( + await this.usersApi.getUser({ + id: encodeHashId(managerUserId)! + }) + ).data + if (!managerUser) { + throw new Error() + } + } catch (e) { + throw new Error( + '`managerUserId` passed to `removeManager` method is invalid.' + ) + } + + return await this.entityManager.manageEntity({ + userId, + entityType: EntityType.GRANT, + entityId: 0, // Contract requires uint, but we don't actually need this field for this action. Just use 0. + action: Action.DELETE, + metadata: JSON.stringify({ + grantee_address: managerUser!.ercWallet + }), + auth: this.auth + }) + } + /** * When user revokes an app's authorization to perform actions on their behalf */ diff --git a/packages/libs/src/sdk/api/grants/types.ts b/packages/libs/src/sdk/api/grants/types.ts index 2a83e5ab598..142ba8d2cd2 100644 --- a/packages/libs/src/sdk/api/grants/types.ts +++ b/packages/libs/src/sdk/api/grants/types.ts @@ -19,6 +19,13 @@ export const AddManagerSchema = z.object({ export type AddManagerRequest = z.input +export const RemoveManagerSchema = z.object({ + userId: HashId, + managerUserId: HashId +}) + +export type RemoveManagerRequest = z.input + export const RevokeGrantSchema = z.object({ userId: HashId, appApiKey: z.custom((data: unknown) => { diff --git a/packages/libs/src/services/discoveryProvider/DiscoveryProvider.ts b/packages/libs/src/services/discoveryProvider/DiscoveryProvider.ts index f53d812e3e8..4842f56f76a 100644 --- a/packages/libs/src/services/discoveryProvider/DiscoveryProvider.ts +++ b/packages/libs/src/services/discoveryProvider/DiscoveryProvider.ts @@ -13,6 +13,7 @@ import type { TransactionReceipt } from 'web3-core' import { DiscoveryNodeSelector, FetchError, Middleware } from '../../sdk' import type { CurrentUser, UserStateManager } from '../../userStateManager' import { CollectionMetadata, Nullable, User, Utils } from '../../utils' +import type { LocalStorage } from '../../utils/localStorage' import type { EthContracts } from '../ethContracts' import type { Web3Manager } from '../web3Manager' @@ -64,6 +65,7 @@ export type DiscoveryProviderConfig = { unhealthySlotDiffPlays?: number unhealthyBlockDiff?: number discoveryNodeSelector?: DiscoveryNodeSelector + enableUserIdOverride?: boolean } & Pick< DiscoveryProviderSelectionConfig, 'selectionCallback' | 'monitoringCallbacks' | 'localStorage' @@ -95,6 +97,17 @@ type DiscoveryNodeChallenge = { disbursed_amount: number } +const getUserIdOverride = async (localStorage?: LocalStorage) => { + try { + const userIdOverride = await localStorage?.getItem( + '@audius/user-id-override' + ) + return userIdOverride == null ? undefined : userIdOverride + } catch { + return undefined + } +} + export type DiscoveryRelayBody = { contractRegistryKey?: string | null contractAddress?: string | null @@ -139,11 +152,14 @@ export class DiscoveryProvider { | DiscoveryProviderSelection['monitoringCallbacks'] | undefined + enableUserIdOverride = false + discoveryProviderEndpoint: string | undefined isInitialized = false discoveryNodeSelector?: DiscoveryNodeSelector discoveryNodeMiddleware?: Middleware selectionCallback?: DiscoveryProviderSelectionConfig['selectionCallback'] + localStorage?: LocalStorage constructor({ whitelist, @@ -159,7 +175,8 @@ export class DiscoveryProvider { localStorage, unhealthySlotDiffPlays, unhealthyBlockDiff, - discoveryNodeSelector + discoveryNodeSelector, + enableUserIdOverride = false }: DiscoveryProviderConfig) { this.whitelist = whitelist this.blacklist = blacklist @@ -167,6 +184,8 @@ export class DiscoveryProvider { this.ethContracts = ethContracts this.web3Manager = web3Manager this.selectionCallback = selectionCallback + this.localStorage = localStorage + this.enableUserIdOverride = enableUserIdOverride this.unhealthyBlockDiff = unhealthyBlockDiff ?? DEFAULT_UNHEALTHY_BLOCK_DIFF this.serviceSelector = new DiscoveryProviderSelection( @@ -820,8 +839,21 @@ export class DiscoveryProvider { * Return user collections (saved & uploaded) along w/ users for those collections */ async getUserAccount(wallet: string) { - const req = Requests.getUserAccount(wallet) - return await this._makeRequest(req) + const userIdOverride = this.enableUserIdOverride + ? await getUserIdOverride(this.localStorage) + : undefined + // If override is used, fetch that account instead + if (userIdOverride) { + const req = Requests.getUsers(1, 0, [parseInt(userIdOverride)]) + const res = await this._makeRequest(req) + if (res && res.length > 0 && res[0]) { + return { ...res[0], playlists: [] } as CurrentUser + } + return null + } else { + const req = Requests.getUserAccount(wallet) + return await this._makeRequest(req) + } } /** diff --git a/packages/web/src/pages/settings-page/components/desktop/ManagerMode/ManagerModeSettingsCard.tsx b/packages/web/src/pages/settings-page/components/desktop/ManagerMode/ManagerModeSettingsCard.tsx new file mode 100644 index 00000000000..3669b63e6c7 --- /dev/null +++ b/packages/web/src/pages/settings-page/components/desktop/ManagerMode/ManagerModeSettingsCard.tsx @@ -0,0 +1,41 @@ +import { useState, useCallback } from 'react' + +import { Button, IconEmbed } from '@audius/harmony' + +import SettingsCard from '../SettingsCard' + +import { ManagerModeSettingsModal } from './ManagerModeSettingsModal' + +const messages = { + title: 'Manager Mode', + description: + 'Allow another Audius user to interact with your account on your behalf.', + buttonText: 'Add Manager' +} + +export const ManagerModeSettingsCard = () => { + const [isModalOpen, setIsModalOpen] = useState(false) + + const handleOpen = useCallback(() => { + setIsModalOpen(true) + }, []) + + const handleClose = useCallback(() => { + setIsModalOpen(false) + }, []) + + return ( + <> + } + title={messages.title} + description={messages.description} + > + + + + + ) +} diff --git a/packages/web/src/pages/settings-page/components/desktop/ManagerMode/ManagerModeSettingsModal.module.css b/packages/web/src/pages/settings-page/components/desktop/ManagerMode/ManagerModeSettingsModal.module.css new file mode 100644 index 00000000000..4c4bbc2b018 --- /dev/null +++ b/packages/web/src/pages/settings-page/components/desktop/ManagerMode/ManagerModeSettingsModal.module.css @@ -0,0 +1,5 @@ +.titleIcon { + height: var(--harmony-unit-6); + width: var(--harmony-unit-6); + margin-top: 2px; +} diff --git a/packages/web/src/pages/settings-page/components/desktop/ManagerMode/ManagerModeSettingsModal.tsx b/packages/web/src/pages/settings-page/components/desktop/ManagerMode/ManagerModeSettingsModal.tsx new file mode 100644 index 00000000000..45cef7b35e3 --- /dev/null +++ b/packages/web/src/pages/settings-page/components/desktop/ManagerMode/ManagerModeSettingsModal.tsx @@ -0,0 +1,194 @@ +import { ChangeEvent, useCallback, useState } from 'react' + +import { useAudiusQueryContext } from '@audius/common/audius-query' +import { accountSelectors } from '@audius/common/store' +import { encodeHashId } from '@audius/common/utils' +import { + Button, + Flex, + IconEmbed, + Modal, + ModalContent, + ModalHeader, + ModalProps, + ModalTitle, + Radio, + RadioGroup, + RadioGroupProps, + Text +} from '@audius/harmony' +import { Form, Formik, useField } from 'formik' +import { useSelector } from 'react-redux' +import { z } from 'zod' +import { toFormikValidationSchema } from 'zod-formik-adapter' + +import { TextField } from 'components/form-fields' + +const messages = { + title: 'Manager Mode', + required: 'Value is required.', + target: 'Target User Id', + request: 'Request', + accept: 'Accept', + reject: 'Reject', + revoke: 'Revoke' +} + +type ManagerModeSettingsModalProps = Omit + +type ManagerModeFormValues = { + target: string + grantType: 'request' | 'accept' | 'reject' | 'revoke' +} + +// TODO: This is prototype code, please don't use it permanently +const useManagerModeState = () => { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [_ignored, setResult] = useState(null) + + const { audiusSdk } = useAudiusQueryContext() + const userIdRaw = useSelector(accountSelectors.getUserId) + + const handleSubmit = useCallback( + async ({ target, grantType }: ManagerModeFormValues) => { + setError(null) + setIsLoading(true) + try { + if (!userIdRaw) throw new Error('User id not found') + + const currentUserId = encodeHashId(userIdRaw) + const targetUserId = encodeHashId(parseInt(target)) + const sdk = await audiusSdk() + let result: any + switch (grantType) { + case 'request': + result = await sdk.grants.addManager({ + userId: currentUserId, + managerUserId: targetUserId + }) + break + // TODO: 'grantor' in these operations is actually 'grantee' during indexing + // so these are swapped for now. Will need to be switched back when the indexing + // is working properly. + // https://linear.app/audius/issue/PAY-2580/indexing-grant-approvals-seems-to-have-swapped-fields + case 'accept': + result = await sdk.grants.approveGrant({ + userId: currentUserId, + grantorUserId: targetUserId + }) + break + case 'reject': + result = await sdk.grants.rejectGrant({ + userId: currentUserId, + grantorUserId: targetUserId + }) + break + case 'revoke': + result = await sdk.grants.removeManager({ + userId: currentUserId, + managerUserId: targetUserId + }) + break + } + console.debug(result) + setResult( + 'Sucessfully submitted. Indexing of operation may take a moment.' + ) + } catch (e) { + console.error(e) + setError(`${e}`) + setResult(null) + } + setIsLoading(false) + }, + [userIdRaw, audiusSdk] + ) + + return { isLoading, handleSubmit, error } +} + +// TODO: Validate this as a user id +const managerModeSchema = toFormikValidationSchema( + z.object({ + target: z.string({ required_error: messages.required }) + }) +) + +const GrantTypeField = (props: RadioGroupProps) => { + const { name, ...other } = props + const [field, _ignored, { setValue }] = useField(name) + + const handleChange = useCallback( + (e: ChangeEvent) => { + const value = e.target.value + setValue(value) + }, + [setValue] + ) + return +} + +export const ManagerModeSettingsModal = ( + props: ManagerModeSettingsModalProps +) => { + const initialValues: ManagerModeFormValues = { + target: '', + grantType: 'request' + } + + const { isLoading, handleSubmit, error } = useManagerModeState() + + return ( + <> + + + } /> + + + + +
+ + + + + + {messages.request} + + + + {messages.accept} + + + + {messages.reject} + + + + {messages.revoke} + + + + + + +
+
+ {error ? ( + + {error} + + ) : null} +
+
+
+ + ) +} diff --git a/packages/web/src/pages/settings-page/components/desktop/SettingsPage.tsx b/packages/web/src/pages/settings-page/components/desktop/SettingsPage.tsx index ef0c46d3c15..e39893e2315 100644 --- a/packages/web/src/pages/settings-page/components/desktop/SettingsPage.tsx +++ b/packages/web/src/pages/settings-page/components/desktop/SettingsPage.tsx @@ -59,6 +59,7 @@ import { import packageInfo from '../../../../../package.json' import { DeveloperAppsSettingsCard } from './DeveloperApps' +import { ManagerModeSettingsCard } from './ManagerMode/ManagerModeSettingsCard' import NotificationSettings from './NotificationSettings' import SettingsCard from './SettingsCard' import styles from './SettingsPage.module.css' @@ -308,6 +309,7 @@ export const SettingsPage = (props: SettingsPageProps) => { const { isEnabled: areDeveloperAppsEnabled } = useFlag( FeatureFlags.DEVELOPER_APPS_PAGE ) + const { isEnabled: isManagerModeEnabled } = useFlag(FeatureFlags.MANAGER_MODE) const isMobile = useIsMobile() const isDownloadDesktopEnabled = !isMobile && !isElectron() @@ -464,6 +466,7 @@ export const SettingsPage = (props: SettingsPageProps) => { ) : null} {areDeveloperAppsEnabled ? : null} + {isManagerModeEnabled ? : null}