Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-shared-constants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
3 changes: 3 additions & 0 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`) |
Expand Down
3 changes: 2 additions & 1 deletion packages/mosaic/src/components/otp/otp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -52,7 +53,7 @@ function OtpSlots({ status, firstSlotRef }: { status: OtpStatus; firstSlotRef: R
*/
export const Otp = React.forwardRef<HTMLInputElement, OtpProps>(function MosaicOtp(
{
length = 6,
length = CODE_LENGTH,
status: statusProp,
disabled: disabledProp,
required: requiredProp,
Expand Down
7 changes: 3 additions & 4 deletions packages/mosaic/src/features/api-keys/api-keys-table.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,19 @@ 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();
const environment = useMosaicEnvironment();
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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileRejectionReason, LocalizableError> = {
accept: { code: 'avatar_file_type_invalid' },
size: { code: 'avatar_file_size_exceeded' },
overflow: { code: 'avatar_file_count_exceeded' },
};

interface OrganizationProfileLogoRowViewProps {
name: string;
imageUrl?: string;
Expand Down Expand Up @@ -62,12 +54,11 @@ export function OrganizationProfileLogoRowView({
<FileUpload.Root
disabled={logo.isPending}
aria-busy={logo.isPending || undefined}
accept={LOGO_MIME_TYPES}
maxSize={LOGO_MAX_BYTES}
{...AVATAR_UPLOAD}
render={<Section.Row />}
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 => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -76,10 +77,8 @@ const unseatedDeps: ReverificationDeps = {
finish: notSeated,
};

export const RESEND_COOLDOWN_MS = 30_000;

function lockResend(): Pick<ReverificationContext, 'resendAvailableAt'> {
return { resendAvailableAt: Date.now() + RESEND_COOLDOWN_MS };
return { resendAvailableAt: Date.now() + CODE_RESEND_SECONDS * 1000 };
}

function unlockResend(): Pick<ReverificationContext, 'resendAvailableAt'> {
Expand Down Expand Up @@ -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,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,7 +43,7 @@ export interface ReverificationOTPProps {
export function ReverificationOTP({
messages,
value,
length = 6,
length = CODE_LENGTH,
errorMessage,
isPending = false,
resend,
Expand Down
6 changes: 3 additions & 3 deletions packages/mosaic/src/features/switcher/switcher.model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 ?? [];
Expand Down
4 changes: 1 addition & 3 deletions packages/mosaic/src/features/switcher/switcher.pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/mosaic/src/features/switcher/switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -58,7 +59,6 @@ function missingDependency(): Promise<never> {
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) })) };
Expand Down Expand Up @@ -168,8 +168,6 @@ const machine = createMachine({
},
});

const CODE_LENGTH = 6;

export function useUserProfileAddEmailController({
initialEmailAddress = '',
username,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
}));
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -73,7 +74,6 @@ type MfaEvent =
};

const { createMachine, assign, fromPromise } = setup<MfaContext, MfaEvent>();
const RESEND_COOLDOWN_SECONDS = 30;
const REVERIFIABLE_ACTIONS: readonly MfaAction[] = ['generate', 'sms', 'prepareAuthenticator'];
const PENDING_METHODS: Partial<Record<MfaAction, NonNullable<UserProfileMfaSetupViewProps['pendingMethod']>>> = {
prepareAuthenticator: 'authenticator',
Expand Down Expand Up @@ -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,
};
}),
Expand Down
Loading
Loading