diff --git a/.changeset/mosaic-shared-constants.md b/.changeset/mosaic-shared-constants.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-shared-constants.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index ffb60db78fe..09308f59e30 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -54,6 +54,9 @@ Most problems a feature hits already have a shared answer. Find the row for your | An inline message row; height is undefined until measured | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` | | Data read from a Clerk resource that should be cached across mounts | A query hook in `src/queries/`, called from the model (`src/queries/use-user-sessions-query.ts`) | | An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` | +| An organization permission checked with `checkAuthorization` | `ORGANIZATION_PERMISSIONS` (`src/utils/permissions.ts`) | +| A verification code's length or resend cooldown | `CODE_LENGTH`, `CODE_RESEND_SECONDS` (`src/utils/verification.ts`) | +| An avatar or logo file picker and its rejection errors | `AVATAR_UPLOAD`, `AVATAR_REJECTION_ERRORS` (`src/utils/avatar-upload.ts`) | | Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) | | An input that reformats as the user types without moving the caret | `useFormattedInput` (`src/primitives/hooks/`) | | An interaction with an async lifecycle or two values that move together | A machine in the controller file, via `setup()` (`src/machine/`) | diff --git a/packages/mosaic/src/components/otp/otp.tsx b/packages/mosaic/src/components/otp/otp.tsx index de6a6f807e4..fba25d56c30 100644 --- a/packages/mosaic/src/components/otp/otp.tsx +++ b/packages/mosaic/src/components/otp/otp.tsx @@ -7,6 +7,7 @@ import type { MosaicStyleProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { inputStyles } from '../../styles/input.styles'; import { reset } from '../../styles/reset.styles'; +import { CODE_LENGTH } from '../../utils/verification'; import { useOptionalFieldControlProps } from '../field/field.context'; import { styles } from './otp.styles'; @@ -52,7 +53,7 @@ function OtpSlots({ status, firstSlotRef }: { status: OtpStatus; firstSlotRef: R */ export const Otp = React.forwardRef(function MosaicOtp( { - length = 6, + length = CODE_LENGTH, status: statusProp, disabled: disabledProp, required: requiredProp, diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.model.ts b/packages/mosaic/src/features/api-keys/api-keys-table.model.ts index 881a4666cd9..1d0738b183e 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.model.ts +++ b/packages/mosaic/src/features/api-keys/api-keys-table.model.ts @@ -6,20 +6,19 @@ import { useEffect, useRef } from 'react'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; import { save } from '../../utils/errors'; +import { ORGANIZATION_PERMISSIONS } from '../../utils/permissions'; import type { APIKeyRecord } from './api-keys-table.types'; import type { CreateAPIKeyInput } from './create-api-key.controller'; export const PAGE_SIZE = 10; -const READ_PERMISSION = 'org:sys_api_keys:read'; -const MANAGE_PERMISSION = 'org:sys_api_keys:manage'; export function useAPIKeysAccess(subject: string | undefined) { const { isLoaded, session } = useSession(); const settings = useMosaicEnvironment()?.apiKeysSettings; const isOrganization = subject !== undefined && isOrganizationId(subject); const can = (permission: string) => !isOrganization || (session?.checkAuthorization({ permission }) ?? false); - const canRead = can(READ_PERMISSION); - const canManage = can(MANAGE_PERMISSION); + const canRead = can(ORGANIZATION_PERMISSIONS.apiKeysRead); + const canManage = can(ORGANIZATION_PERMISSIONS.apiKeysManage); const isAvailable = subject !== undefined && (isOrganization diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.model.ts b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.model.ts index f9840626c5d..a0b32d4fbaf 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.model.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.model.ts @@ -2,6 +2,7 @@ import { useOrganization, useOrganizationList, useSession, useUser } from '@cler import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; +import { ORGANIZATION_PERMISSIONS } from '../../../utils/permissions'; import { useOrganizationProfileOptions } from '../organization-profile.provider'; const organizationListParams = { @@ -50,7 +51,7 @@ export function useOrganizationProfileDangerSectionModel(): OrganizationProfileD const canDelete = Boolean(membership) && organization.adminDeleteEnabled && - (session?.checkAuthorization({ permission: 'org:sys_profile:delete' }) ?? false); + (session?.checkAuthorization({ permission: ORGANIZATION_PERMISSIONS.profileDelete }) ?? false); return { status: 'ready', diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.model.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.model.ts index a3b7872ad39..d83ec7de682 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.model.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.model.ts @@ -3,10 +3,10 @@ import { useClerk, useOrganization, useSession } from '@clerk/shared/react'; import type { ClerkAPIError, OrganizationInvitationResource } from '@clerk/shared/types'; import { save } from '../../../utils/errors'; +import { ORGANIZATION_PERMISSIONS } from '../../../utils/permissions'; import type { MembersRoles } from './members-table-tab.types'; const PAGE_SIZE = 10; -const MANAGE_PERMISSION = 'org:sys_memberships:manage'; export interface InviteMembersInput { emailAddresses: string[]; @@ -18,7 +18,7 @@ export interface InviteMembersInput { export function useInvitationsTableModel() { const clerk = useClerk(); const { session } = useSession(); - const canManage = session?.checkAuthorization({ permission: MANAGE_PERMISSION }) ?? false; + const canManage = session?.checkAuthorization({ permission: ORGANIZATION_PERMISSIONS.membershipsManage }) ?? false; const { organization, invitations } = useOrganization({ invitations: canManage ? { pageSize: PAGE_SIZE, keepPreviousData: true } : undefined, }); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.model.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.model.ts index 7bcfbb5e682..9723390b96b 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.model.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.model.ts @@ -3,16 +3,15 @@ import { useOrganization, useSession } from '@clerk/shared/react'; import type { OrganizationMembershipResource } from '@clerk/shared/types'; import { useEffect, useRef } from 'react'; +import { ORGANIZATION_PERMISSIONS } from '../../../utils/permissions'; import type { MembersRoles, OrganizationProfileMember } from './members-table-tab.types'; const PAGE_SIZE = 10; -const READ_PERMISSION = 'org:sys_memberships:read'; -const MANAGE_PERMISSION = 'org:sys_memberships:manage'; export function useMembersTableModel(query: string) { const { session } = useSession(); - const canRead = session?.checkAuthorization({ permission: READ_PERMISSION }) ?? false; - const canManage = session?.checkAuthorization({ permission: MANAGE_PERMISSION }) ?? false; + const canRead = session?.checkAuthorization({ permission: ORGANIZATION_PERMISSIONS.membershipsRead }) ?? false; + const canManage = session?.checkAuthorization({ permission: ORGANIZATION_PERMISSIONS.membershipsManage }) ?? false; const { organization, memberships } = useOrganization({ memberships: canRead ? { pageSize: PAGE_SIZE, keepPreviousData: true, query: query || undefined } : undefined, }); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-members-panel.model.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-members-panel.model.ts index f7024b61bef..f7d8ca287dc 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-members-panel.model.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-members-panel.model.ts @@ -2,11 +2,9 @@ import { useOrganization, useSession } from '@clerk/shared/react'; import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useOrganizationRolesQuery } from '../../../queries/use-organization-roles-query'; +import { ORGANIZATION_PERMISSIONS } from '../../../utils/permissions'; import type { MembersPanelRoles } from './members-table-tab.types'; -const READ_PERMISSION = 'org:sys_memberships:read'; -const MANAGE_PERMISSION = 'org:sys_memberships:manage'; - export function useMembersPanelAccessModel() { const { isLoaded, organization } = useOrganization(); const { isLoaded: isSessionLoaded, session } = useSession(); @@ -14,8 +12,9 @@ export function useMembersPanelAccessModel() { if (!isLoaded || !isSessionLoaded || !environment) { return { status: 'loading' as const }; } - const canReadMembers = session?.checkAuthorization({ permission: READ_PERMISSION }) ?? false; - const canManageInvitations = session?.checkAuthorization({ permission: MANAGE_PERMISSION }) ?? false; + const canReadMembers = session?.checkAuthorization({ permission: ORGANIZATION_PERMISSIONS.membershipsRead }) ?? false; + const canManageInvitations = + session?.checkAuthorization({ permission: ORGANIZATION_PERMISSIONS.membershipsManage }) ?? false; const canManageRequests = canManageInvitations && environment.organizationSettings.domains.enabled; if (!organization || !session || (!canReadMembers && !canManageInvitations)) { return { status: 'hidden' as const }; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-logo-row.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-logo-row.view.tsx index 2da9b91bd0b..14e14a4c582 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-logo-row.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-logo-row.view.tsx @@ -9,19 +9,11 @@ import { Pen } from '../../../icons/glyphs/pen'; import { X } from '../../../icons/glyphs/x'; import type { LocalizableError } from '../../../localization'; import { useErrorText, useMessages } from '../../../localization'; -import type { FileRejection, FileRejectionReason } from '../../../primitives/file-upload'; +import type { FileRejection } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; +import { AVATAR_REJECTION_ERRORS, AVATAR_UPLOAD } from '../../../utils/avatar-upload'; import { useOrganizationProfileLogoController } from './organization-profile-logo.controller'; -const LOGO_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; -const LOGO_MAX_BYTES = 10 * 1000 * 1000; - -const REJECTION_ERRORS: Record = { - accept: { code: 'avatar_file_type_invalid' }, - size: { code: 'avatar_file_size_exceeded' }, - overflow: { code: 'avatar_file_count_exceeded' }, -}; - interface OrganizationProfileLogoRowViewProps { name: string; imageUrl?: string; @@ -62,12 +54,11 @@ export function OrganizationProfileLogoRowView({ } onReject={rejections => { const rejected = rejections[0]; - setRejection(rejected ? REJECTION_ERRORS[rejected.reason] : undefined); + setRejection(rejected ? AVATAR_REJECTION_ERRORS[rejected.reason] : undefined); onReject?.(rejections); }} onValueChange={files => { diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.model.ts b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.model.ts index 76dbbf1ce2a..758ea42432e 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.model.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.model.ts @@ -3,6 +3,7 @@ import type { OrganizationResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { save, SaveError, UNEXPECTED_ERROR } from '../../../utils/errors'; +import { ORGANIZATION_PERMISSIONS } from '../../../utils/permissions'; import type { OrganizationProfileProfileSectionViewProps } from './organization-profile-profile-section.view'; type OrganizationProfileProfileSectionModel = @@ -25,7 +26,8 @@ export function useOrganizationProfileProfileSectionModel(): OrganizationProfile const organizationId = organization.id; const canManage = - Boolean(membership) && (session?.checkAuthorization({ permission: 'org:sys_profile:manage' }) ?? false); + Boolean(membership) && + (session?.checkAuthorization({ permission: ORGANIZATION_PERMISSIONS.profileManage }) ?? false); const currentOrganization = (): OrganizationResource => { const current = clerk.organization; if (!current || current.id !== organizationId) { diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts index 6e863248763..d9ca5d4960a 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -8,6 +8,7 @@ import { setup } from '../../machine/setup'; import type { DoneInvokeEvent, ErrorInvokeEvent, StateConfig } from '../../machine/types'; import { useMachine } from '../../machine/use-machine'; import { toLocalizableError } from '../../utils/errors'; +import { CODE_RESEND_SECONDS } from '../../utils/verification'; import type { ReverificationActiveModel, ReverificationModel } from './reverification.model'; import type { ReverificationErrorReason, @@ -76,10 +77,8 @@ const unseatedDeps: ReverificationDeps = { finish: notSeated, }; -export const RESEND_COOLDOWN_MS = 30_000; - function lockResend(): Pick { - return { resendAvailableAt: Date.now() + RESEND_COOLDOWN_MS }; + return { resendAvailableAt: Date.now() + CODE_RESEND_SECONDS * 1000 }; } function unlockResend(): Pick { @@ -527,7 +526,7 @@ function useReverificationState(model: ReverificationModel): ReverificationContr // useSyncExternalStore feels unnecessarily complex here resendRemainingSeconds: countingDown && resendAvailableAt - ? Math.min(RESEND_COOLDOWN_MS / 1000, Math.max(0, Math.ceil((resendAvailableAt - now) / 1000))) + ? Math.min(CODE_RESEND_SECONDS, Math.max(0, Math.ceil((resendAvailableAt - now) / 1000))) : undefined, }; diff --git a/packages/mosaic/src/features/reverification/steps/reverification-otp.tsx b/packages/mosaic/src/features/reverification/steps/reverification-otp.tsx index ec7d1c5b124..b9b8efd4610 100644 --- a/packages/mosaic/src/features/reverification/steps/reverification-otp.tsx +++ b/packages/mosaic/src/features/reverification/steps/reverification-otp.tsx @@ -8,6 +8,7 @@ import { Field } from '../../../components/field'; import { useFlowAutoFocus } from '../../../components/flow'; import { Otp } from '../../../components/otp'; import { Form } from '../../../primitives/form'; +import { CODE_LENGTH } from '../../../utils/verification'; export interface ReverificationOtpMessages { title: string; @@ -42,7 +43,7 @@ export interface ReverificationOTPProps { export function ReverificationOTP({ messages, value, - length = 6, + length = CODE_LENGTH, errorMessage, isPending = false, resend, diff --git a/packages/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx index 26b51fd4ffd..1a7f9266047 100644 --- a/packages/mosaic/src/features/switcher/switcher.model.tsx +++ b/packages/mosaic/src/features/switcher/switcher.model.tsx @@ -17,6 +17,7 @@ import { useMosaicRouter } from '../../hooks/use-mosaic-router'; import { useModal } from '../../modals/use-modal'; import { useGetPortalContainer } from '../../primitives/portal'; import { replaceInPages, shrinkTotal } from '../../utils/infinite-pages'; +import { ORGANIZATION_PERMISSIONS } from '../../utils/permissions'; import { organizationProfileModal } from '../organization-profile/organization-profile.modal'; import type { OrganizationProfileModalOptions } from '../organization-profile/organization-profile.modal.types'; import { userProfileModal } from '../user-profile/user-profile.modal'; @@ -161,8 +162,6 @@ function openOrNavigate({ return opensModal(url, mode) ? () => openModal() : () => void navigate(url ?? buildUrl()); } -const INVITE_MEMBERS_PERMISSION = 'org:sys_memberships:manage'; - function displayName(user: UserResource): string { return getFullName(user) || getIdentifier(user); } @@ -288,7 +287,8 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch const { enabled: organizationsEnabled, forceOrganizationSelection } = organizationSettings; const { singleSessionMode } = authConfig; - const canInviteMembers = session.checkAuthorization({ permission: INVITE_MEMBERS_PERMISSION }) ?? false; + const canInviteMembers = + session.checkAuthorization({ permission: ORGANIZATION_PERMISSIONS.membershipsManage }) ?? false; const membershipData = userMemberships.data ?? []; const suggestionData = userSuggestions.data ?? []; const invitationData = userInvitations.data ?? []; diff --git a/packages/mosaic/src/features/switcher/switcher.pages.tsx b/packages/mosaic/src/features/switcher/switcher.pages.tsx index 301e64b0d0c..fe0598ab6f9 100644 --- a/packages/mosaic/src/features/switcher/switcher.pages.tsx +++ b/packages/mosaic/src/features/switcher/switcher.pages.tsx @@ -14,12 +14,10 @@ import { createPortal } from 'react-dom'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; import { applyOrder } from '../../utils/apply-order'; +import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types'; import { USER_PROFILE_PAGE_IDS } from '../user-profile/user-profile.layout'; import type { CustomProfileItem, CustomProfileLink, UserProfilePageId } from '../user-profile/user-profile.types'; -/** A built-in OrganizationProfile page, by the id its navigation uses. */ -export type OrganizationProfilePageId = 'general' | 'members' | 'billing' | 'apiKeys' | 'security'; - /** * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned * off. diff --git a/packages/mosaic/src/features/switcher/switcher.tsx b/packages/mosaic/src/features/switcher/switcher.tsx index 1bcd7417dbf..e5ea93af61b 100644 --- a/packages/mosaic/src/features/switcher/switcher.tsx +++ b/packages/mosaic/src/features/switcher/switcher.tsx @@ -3,11 +3,11 @@ import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types'; import type { ReactElement, ReactNode } from 'react'; +import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types'; import type { CustomProfileItem, CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types'; import { useSwitcherController } from './switcher.controller'; import type { SwitcherModelOptions } from './switcher.model'; import { useSwitcherModel } from './switcher.model'; -import type { OrganizationProfilePageId } from './switcher.pages'; import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from './switcher.pages'; import type { SwitcherMenuProps, SwitcherMode } from './switcher.types'; import type { SwitcherTriggerProps, SwitcherTriggerVariant } from './switcher.view'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts index cf7ca679023..048e1a302ba 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts @@ -9,6 +9,7 @@ import type { ErrorInvokeEvent } from '../../../machine/types'; import { useMachine } from '../../../machine/use-machine'; import type { FormError } from '../../../utils/errors'; import { toFormError } from '../../../utils/errors'; +import { CODE_LENGTH, CODE_RESEND_SECONDS } from '../../../utils/verification'; import type { ReverificationController } from '../../reverification'; import type { UserProfileEmail, @@ -58,7 +59,6 @@ function missingDependency(): Promise { return Promise.reject(new Error('Email verification is missing')); } -const CODE_RESEND_SECONDS = 30; const LINK_RESEND_SECONDS = 60; const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; @@ -168,8 +168,6 @@ const machine = createMachine({ }, }); -const CODE_LENGTH = 6; - export function useUserProfileAddEmailController({ initialEmailAddress = '', username, diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts index 5061855edac..0c43e042e4e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts @@ -8,7 +8,7 @@ import type { ReverificationController } from '../../reverification'; import { useReverificationFlow } from '../../reverification'; import { withUserProfileModalReturn } from '../user-profile.modal-return'; import { useUserProfileOptions } from '../user-profile.provider'; -import { isMonochromeProvider } from '../user-profile-provider.utils'; +import { isMonochromeProvider, providerId } from '../user-profile-provider.utils'; import type { EnterpriseAccountActionResult, UserProfileEnterpriseAccount, @@ -52,7 +52,7 @@ export function projectEnterpriseAccounts({ id: account.id ?? '', name: account.enterpriseConnection?.name ?? '', iconUrl: account.enterpriseConnection?.logoPublicUrl ?? undefined, - monochromeIcon: isMonochromeProvider(account.provider.replace(/^(oauth_|saml_)/, '').trim()), + monochromeIcon: isMonochromeProvider(providerId(account.provider)), emailAddress: account.emailAddress, requiresAction: Boolean(account.verification?.error?.longMessage), })); @@ -62,7 +62,7 @@ export function projectEnterpriseAccounts({ id: connection.id, name: connection.name, iconUrl: connection.logoPublicUrl ?? undefined, - monochromeIcon: isMonochromeProvider(connection.provider.replace(/^(oauth_|saml_)/, '').trim()), + monochromeIcon: isMonochromeProvider(providerId(connection.provider)), })); return visibleAccounts.length || linkableConnections.length diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts index aa7cc25fb31..9602f10ca77 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts @@ -6,6 +6,7 @@ import { setup } from '../../../machine/setup'; import type { DoneInvokeEvent } from '../../../machine/types'; import { useMachine } from '../../../machine/use-machine'; import { toLocalizableError } from '../../../utils/errors'; +import { CODE_RESEND_SECONDS } from '../../../utils/verification'; import type { ReverificationController } from '../../reverification'; import type { SmsEnrollmentResult, @@ -73,7 +74,6 @@ type MfaEvent = }; const { createMachine, assign, fromPromise } = setup(); -const RESEND_COOLDOWN_SECONDS = 30; const REVERIFIABLE_ACTIONS: readonly MfaAction[] = ['generate', 'sms', 'prepareAuthenticator']; const PENDING_METHODS: Partial>> = { prepareAuthenticator: 'authenticator', @@ -236,7 +236,7 @@ const mfaMachine = createMachine({ resendSeconds: context.pending === 'resend' || (context.pending === 'sms' && event.output.kind === 'sms' && event.output.step === 'verify') - ? RESEND_COOLDOWN_SECONDS + ? CODE_RESEND_SECONDS : context.resendSeconds, }; }), diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts index 12690e02cd0..7db589f8e18 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts @@ -9,6 +9,7 @@ import type { ErrorInvokeEvent } from '../../../machine/types'; import { useMachine } from '../../../machine/use-machine'; import type { FormError } from '../../../utils/errors'; import { toFormError } from '../../../utils/errors'; +import { CODE_RESEND_SECONDS } from '../../../utils/verification'; import type { ReverificationController } from '../../reverification'; import type { UserProfilePhone, UserProfilePhoneVerifier } from '../user-profile-contact.types'; import type { UserProfileAddPhoneDialogProps } from './user-profile-add-phone.dialog'; @@ -49,8 +50,6 @@ function missingDependency(): Promise { return Promise.reject(new Error('Phone verification is missing')); } -const CODE_RESEND_SECONDS = 30; - const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; const fail = assign((_, event) => ({ error: toFormError(event.error) })); const start = { diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx index 7767d37d58f..4b909999d0c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx @@ -15,8 +15,7 @@ import { PhoneInput } from '../../components/phone-input'; import { fill, rich, useMessages } from '../../localization'; import { Form } from '../../primitives/form'; import { tabularNumbersStyle } from '../../styles/typography.styles'; - -const CODE_LENGTH = 6; +import { CODE_LENGTH } from '../../utils/verification'; interface EnterPhoneStepProps { inputRef?: Ref; diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx index 644d9956990..9bcaa255b84 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx @@ -5,12 +5,9 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; import { FileUpload } from '../../../primitives/file-upload'; +import { AVATAR_UPLOAD } from '../../../utils/avatar-upload'; import type { UserProfilePictureController } from './user-profile-picture.controller'; -const PROFILE_PICTURE_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; -/** Matches the limit the row's own description advertises. */ -const PROFILE_PICTURE_MAX_BYTES = 10 * 1000 * 1000; - export interface UserProfilePictureRowViewProps extends UserProfilePictureController { name: string; imageUrl?: string; @@ -38,8 +35,7 @@ export function UserProfilePictureRowView({ return ( } diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture.controller.ts index 4be2d86f88c..da3ae10c065 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture.controller.ts @@ -3,15 +3,9 @@ import { useState } from 'react'; import { usePendingAction } from '../../../hooks/use-pending-action'; import type { LocalizableError } from '../../../localization'; import { useErrorText } from '../../../localization'; -import type { FileRejection, FileRejectionReason } from '../../../primitives/file-upload'; +import type { FileRejection } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; - -/** Rejecting a pick locally reads the same as the server rejecting the upload. */ -const REJECTION_ERRORS: Record = { - accept: { code: 'avatar_file_type_invalid' }, - size: { code: 'avatar_file_size_exceeded' }, - overflow: { code: 'avatar_file_count_exceeded' }, -}; +import { AVATAR_REJECTION_ERRORS } from '../../../utils/avatar-upload'; export interface UserProfilePictureControllerOptions { onChange?: (file: File) => Promise; @@ -49,7 +43,7 @@ export function useUserProfilePictureController({ onChange: onChange ? file => void save(file, () => onChange(file)) : undefined, onReject: rejections => { const rejected = rejections[0]; - setRejection(rejected ? REJECTION_ERRORS[rejected.reason] : undefined); + setRejection(rejected ? AVATAR_REJECTION_ERRORS[rejected.reason] : undefined); }, onRemove: onRemove ? () => void save(undefined, onRemove) : undefined, isPending: upload.isPending, diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.model.ts index 3500cc19a3c..f1b4bcdaa21 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.model.ts @@ -5,6 +5,7 @@ import { save } from '../../../utils/errors'; import { useReverificationFlow } from '../../reverification'; import { isAttributeAvailable } from '../user-profile.utils'; import type { UserProfileManagedBy } from '../user-profile-managed-by'; +import { providerId } from '../user-profile-provider.utils'; import { useUserProfileUserModel } from '../user-profile-user.model'; import type { UserProfileEditNameField } from './user-profile-edit-name.dialog'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; @@ -18,7 +19,7 @@ function toManagedBy(account: EnterpriseAccountResource | undefined): UserProfil return undefined; } const connection = account.enterpriseConnection; - return { name: connection?.name || account.provider.replace(/^(oauth_|saml_)/, '') }; + return { name: connection?.name || providerId(account.provider) }; } function toNameAttribute(attribute: AttributeData | undefined): UserProfileNameAttribute { diff --git a/packages/mosaic/src/features/user-profile/user-profile-provider.utils.ts b/packages/mosaic/src/features/user-profile/user-profile-provider.utils.ts index 1ba6ea11079..f23b6c8b04c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-provider.utils.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-provider.utils.ts @@ -3,3 +3,7 @@ const MONOCHROME_PROVIDERS = ['agentid', 'apple', 'github', 'okta', 'okx_wallet' export function isMonochromeProvider(provider: string): boolean { return MONOCHROME_PROVIDERS.includes(provider); } + +export function providerId(provider: string): string { + return provider.replace(/^(oauth_|saml_)/, '').trim(); +} diff --git a/packages/mosaic/src/utils/avatar-upload.ts b/packages/mosaic/src/utils/avatar-upload.ts new file mode 100644 index 00000000000..f7d209749ed --- /dev/null +++ b/packages/mosaic/src/utils/avatar-upload.ts @@ -0,0 +1,15 @@ +import type { LocalizableError } from '../localization'; +import type { FileRejectionReason } from '../primitives/file-upload'; + +export const AVATAR_UPLOAD = { + accept: 'image/png,image/jpeg,image/gif,image/webp', + /** Matches the limit the row's own description advertises. */ + maxSize: 10 * 1000 * 1000, +} as const; + +/** Rejecting a pick locally reads the same as the server rejecting the upload. */ +export const AVATAR_REJECTION_ERRORS: Record = { + accept: { code: 'avatar_file_type_invalid' }, + size: { code: 'avatar_file_size_exceeded' }, + overflow: { code: 'avatar_file_count_exceeded' }, +}; diff --git a/packages/mosaic/src/utils/permissions.ts b/packages/mosaic/src/utils/permissions.ts new file mode 100644 index 00000000000..9836133848f --- /dev/null +++ b/packages/mosaic/src/utils/permissions.ts @@ -0,0 +1,8 @@ +export const ORGANIZATION_PERMISSIONS = { + apiKeysRead: 'org:sys_api_keys:read', + apiKeysManage: 'org:sys_api_keys:manage', + membershipsRead: 'org:sys_memberships:read', + membershipsManage: 'org:sys_memberships:manage', + profileManage: 'org:sys_profile:manage', + profileDelete: 'org:sys_profile:delete', +} as const; diff --git a/packages/mosaic/src/utils/verification.ts b/packages/mosaic/src/utils/verification.ts new file mode 100644 index 00000000000..090632427c5 --- /dev/null +++ b/packages/mosaic/src/utils/verification.ts @@ -0,0 +1,2 @@ +export const CODE_LENGTH = 6; +export const CODE_RESEND_SECONDS = 30;