diff --git a/packages/core/src/i18n/custom-messages/my-organization/index.ts b/packages/core/src/i18n/custom-messages/my-organization/index.ts index 27bc62393..845ecef45 100644 --- a/packages/core/src/i18n/custom-messages/my-organization/index.ts +++ b/packages/core/src/i18n/custom-messages/my-organization/index.ts @@ -7,3 +7,5 @@ export * from './idp-management'; export * from './organization-management'; export * from './domain-management'; +export * from './member-management/invitation-tab-types'; +export * from './member-management/member-management-types'; diff --git a/packages/core/src/i18n/custom-messages/my-organization/member-management/invitation-tab-types.ts b/packages/core/src/i18n/custom-messages/my-organization/member-management/invitation-tab-types.ts new file mode 100644 index 000000000..e51228669 --- /dev/null +++ b/packages/core/src/i18n/custom-messages/my-organization/member-management/invitation-tab-types.ts @@ -0,0 +1,90 @@ +/** + * Custom message type definitions for invitation tab. + * @module invitation-tab-types + * @internal + */ + +export interface OrganizationInvitationTabMessages { + table?: { + columns?: { + email?: string; + status?: string; + inviter?: string; + created_at?: string; + expires_at?: string; + roles?: string; + }; + empty_message?: string; + search_placeholder?: string; + filter_by_role?: string; + all_roles?: string; + reset_filter?: string; + showing_results?: string; + status_pending?: string; + status_expired?: string; + }; + actions?: { + menu_label?: string; + view_details?: string; + copy_url?: string; + revoke_and_resend?: string; + revoke?: string; + }; + create?: { + title?: string; + description?: string; + email_label?: string; + email_placeholder?: string; + email_helper?: string; + email_limit_error?: string; + email_invalid_error?: string; + email_duplicate_error?: string; + email_required_error?: string; + roles_label?: string; + roles_placeholder?: string; + provider_label?: string; + provider_placeholder?: string; + submit_button?: string; + creating?: string; + cancel_button?: string; + }; + details?: { + title?: string; + email_label?: string; + status_label?: string; + roles_label?: string; + provider_label?: string; + created_at_label?: string; + expires_at_label?: string; + invited_by_label?: string; + invitation_url_label?: string; + copy_url_button?: string; + close_button?: string; + revoke_button?: string; + resend_button?: string; + }; + revoke?: { + title?: string; + description?: string; + confirm_button?: string; + cancel_button?: string; + }; + revoke_resend?: { + title?: string; + description?: string; + confirm_button?: string; + cancel_button?: string; + }; + success?: { + url_copied?: string; + invitation_resent?: string; + }; + error?: { + fetch_failed?: string; + create_failed?: string; + revoke_failed?: string; + resend_failed?: string; + revoke_resend_failed?: string; + copy_url_failed?: string; + }; +} diff --git a/packages/core/src/i18n/custom-messages/my-organization/member-management/member-management-types.ts b/packages/core/src/i18n/custom-messages/my-organization/member-management/member-management-types.ts new file mode 100644 index 000000000..60bfc9796 --- /dev/null +++ b/packages/core/src/i18n/custom-messages/my-organization/member-management/member-management-types.ts @@ -0,0 +1,19 @@ +/** + * Custom message type definitions for member management. + * @module member-management-types + * @internal + */ + +import type { OrganizationInvitationTabMessages } from './invitation-tab-types'; + +export interface OrganizationMemberManagementMessages { + header?: { + title?: string; + description?: string; + }; + tabs?: { + members?: string; + invitations?: string; + }; + invitation?: OrganizationInvitationTabMessages; +} diff --git a/packages/core/src/i18n/translations/en-US.json b/packages/core/src/i18n/translations/en-US.json index 2cd7c8218..2cfc2478d 100644 --- a/packages/core/src/i18n/translations/en-US.json +++ b/packages/core/src/i18n/translations/en-US.json @@ -1069,5 +1069,124 @@ "install_guardian_description": "In order to continue, install the Auth0 Guardian app via the app store from your mobile device" } } + }, + "member_management": { + "header": { + "title": "Members", + "description": "Manage the members of your organization." + }, + "invite_button": "Invite Member", + "tabs": { + "members": "Members", + "invitations": "Invitations" + }, + "member": { + "table": { + "columns": { + "name": "Name", + "email": "Email", + "roles": "Roles" + }, + "empty_message": "No members found." + }, + "remove": { + "title": "Remove Member", + "description": "Are you sure you want to remove ${name} from this organization?", + "confirm_button": "Remove", + "cancel_button": "Cancel", + "success": "${name} has been removed from the organization." + }, + "error": { + "fetch_failed": "Failed to load members. Please try again.", + "remove_failed": "Failed to remove member. Please try again." + } + }, + "invitation": { + "table": { + "columns": { + "email": "Email", + "status": "Status", + "inviter": "Invited By", + "created_at": "Created At", + "expires_at": "Expires At", + "roles": "Roles" + }, + "empty_message": "No pending invitations.", + "search_placeholder": "Search by email...", + "filter_by_role": "Filter By Role", + "all_roles": "All", + "reset_filter": "Reset", + "showing_results": "Showing ${start}-${end} of ${total}", + "status_pending": "Pending", + "status_expired": "Expired" + }, + "actions": { + "menu_label": "Actions", + "view_details": "View Details", + "copy_url": "Copy Invitation URL", + "revoke_and_resend": "Revoke and Resend", + "revoke": "Revoke Invitation" + }, + "create": { + "title": "Invite Member", + "description": "Send an invitation to join this organization.", + "email_label": "Email Address", + "email_placeholder": "Enter email address and press Enter", + "email_helper": "Add up to 10 members in a comma-separated list.", + "email_limit_error": "You can add up to 10 email addresses.", + "email_invalid_error": "Please enter a valid email address.", + "email_duplicate_error": "This email has already been added.", + "email_required_error": "Please enter at least one email address.", + "roles_label": "Roles", + "roles_placeholder": "Select roles (optional)", + "provider_label": "Provider", + "provider_placeholder": "Select provider (optional)", + "provider_helper": "If none is selected, the member can log in with any provider.", + "submit_button": "Send Invite", + "creating": "Creating...", + "cancel_button": "Cancel", + "success": "Invitation sent to ${email}." + }, + "details": { + "title": "Invitation Details", + "email_label": "Email", + "status_label": "Status", + "roles_label": "Roles", + "provider_label": "Identity Provider", + "created_at_label": "Created", + "expires_at_label": "Expires", + "invited_by_label": "Invited By", + "invitation_url_label": "Invitation URL", + "copy_url_button": "Copy", + "close_button": "Close", + "revoke_button": "Revoke Invitation", + "resend_button": "Revoke and Resend" + }, + "revoke": { + "title": "Revoke Invitation", + "description": "Are you sure you want to revoke the invitation to ${email}?", + "confirm_button": "Revoke", + "cancel_button": "Cancel", + "success": "Invitation for ${email} has been revoked." + }, + "revoke_resend": { + "title": "Revoke and Resend Invitation", + "description": "Are you sure you want to revoke the current invitation and send a new one to ${email}?", + "confirm_button": "Proceed", + "cancel_button": "Cancel" + }, + "success": { + "url_copied": "Invitation URL copied to clipboard.", + "invitation_resent": "Invitation resent to ${email}." + }, + "error": { + "fetch_failed": "Failed to load invitations. Please try again.", + "create_failed": "Failed to send invitation. Please try again.", + "revoke_failed": "Failed to revoke invitation. Please try again.", + "resend_failed": "Failed to resend invitation. Please try again.", + "revoke_resend_failed": "Failed to revoke and resend invitation. Please try again.", + "copy_url_failed": "Failed to copy invitation URL. Please try again." + } + } } } diff --git a/packages/core/src/i18n/translations/ja.json b/packages/core/src/i18n/translations/ja.json index 73bc5d62b..a877c73a4 100644 --- a/packages/core/src/i18n/translations/ja.json +++ b/packages/core/src/i18n/translations/ja.json @@ -1071,5 +1071,124 @@ "install_guardian_description": "続行するには、お使いのモバイルデバイスからアプリストア経由でAuth0 Guardianアプリをインストールしてください。" } } + }, + "member_management": { + "header": { + "title": "メンバー", + "description": "組織のメンバーを管理します。" + }, + "invite_button": "メンバーを招待", + "tabs": { + "members": "メンバー", + "invitations": "招待" + }, + "member": { + "table": { + "columns": { + "name": "名前", + "email": "メール", + "roles": "ロール" + }, + "empty_message": "メンバーが見つかりません。" + }, + "remove": { + "title": "メンバーを削除", + "description": "この組織から${name}を削除してもよろしいですか?", + "confirm_button": "削除", + "cancel_button": "キャンセル", + "success": "${name}が組織から削除されました。" + }, + "error": { + "fetch_failed": "メンバーの読み込みに失敗しました。もう一度お試しください。", + "remove_failed": "メンバーの削除に失敗しました。もう一度お試しください。" + } + }, + "invitation": { + "table": { + "columns": { + "email": "メール", + "status": "ステータス", + "inviter": "招待者", + "created_at": "作成日", + "expires_at": "有効期限", + "roles": "ロール" + }, + "empty_message": "保留中の招待はありません。", + "search_placeholder": "メールで検索...", + "filter_by_role": "ロールで絞り込み", + "all_roles": "すべて", + "reset_filter": "リセット", + "showing_results": "${start}〜${end} / ${total}件", + "status_pending": "保留中", + "status_expired": "期限切れ" + }, + "actions": { + "menu_label": "アクション", + "view_details": "詳細を表示", + "copy_url": "招待URLをコピー", + "revoke_and_resend": "取り消して再送信", + "revoke": "招待を取り消す" + }, + "create": { + "title": "メンバーを招待", + "description": "この組織に参加するための招待を送信します。", + "email_label": "メールアドレス", + "email_placeholder": "メールアドレスを入力してEnterを押してください", + "email_helper": "カンマ区切りで最大10名まで追加できます。", + "email_limit_error": "メールアドレスは最大10件まで追加できます。", + "email_invalid_error": "有効なメールアドレスを入力してください。", + "email_duplicate_error": "このメールアドレスは既に追加されています。", + "email_required_error": "メールアドレスを1つ以上入力してください。", + "roles_label": "ロール", + "roles_placeholder": "ロールを選択(任意)", + "provider_label": "プロバイダー", + "provider_placeholder": "プロバイダーを選択(任意)", + "provider_helper": "選択しない場合、メンバーは任意のプロバイダーでログインできます。", + "submit_button": "招待を送信", + "creating": "作成中...", + "cancel_button": "キャンセル", + "success": "${email}に招待を送信しました。" + }, + "details": { + "title": "招待の詳細", + "email_label": "メール", + "status_label": "ステータス", + "roles_label": "ロール", + "provider_label": "IDプロバイダー", + "created_at_label": "作成日", + "expires_at_label": "有効期限", + "invited_by_label": "招待者", + "invitation_url_label": "招待URL", + "copy_url_button": "コピー", + "close_button": "閉じる", + "revoke_button": "招待を取り消す", + "resend_button": "取り消して再送信" + }, + "revoke": { + "title": "招待を取り消す", + "description": "${email}への招待を取り消してもよろしいですか?", + "confirm_button": "取り消す", + "cancel_button": "キャンセル", + "success": "${email}への招待が取り消されました。" + }, + "revoke_resend": { + "title": "招待を取り消して再送信", + "description": "現在の招待を取り消し、${email}に新しい招待を送信してもよろしいですか?", + "confirm_button": "続行", + "cancel_button": "キャンセル" + }, + "success": { + "url_copied": "招待URLをクリップボードにコピーしました。", + "invitation_resent": "${email}に招待を再送信しました。" + }, + "error": { + "fetch_failed": "招待の読み込みに失敗しました。もう一度お試しください。", + "create_failed": "招待の送信に失敗しました。もう一度お試しください。", + "revoke_failed": "招待の取り消しに失敗しました。もう一度お試しください。", + "resend_failed": "招待の再送信に失敗しました。もう一度お試しください。", + "revoke_resend_failed": "招待の取り消しと再送信に失敗しました。もう一度お試しください。", + "copy_url_failed": "招待URLのコピーに失敗しました。もう一度お試しください。" + } + } } } diff --git a/packages/core/src/schemas/my-organization/index.ts b/packages/core/src/schemas/my-organization/index.ts index 28b4269df..c800eec7e 100644 --- a/packages/core/src/schemas/my-organization/index.ts +++ b/packages/core/src/schemas/my-organization/index.ts @@ -7,3 +7,6 @@ export * from './organization-management'; export * from './idp-management'; export * from './domain-management'; +export * from './member-management/invitations/invitation-schema'; +export * from './member-management/invitations/invitation-create-schema'; +export * from './member-management/invitations/invitation-create-schema-types'; diff --git a/packages/core/src/schemas/my-organization/member-management/invitations/__tests__/invitation-create-schema.test.ts b/packages/core/src/schemas/my-organization/member-management/invitations/__tests__/invitation-create-schema.test.ts new file mode 100644 index 000000000..e24e90ec0 --- /dev/null +++ b/packages/core/src/schemas/my-organization/member-management/invitations/__tests__/invitation-create-schema.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest'; + +import { + createInvitationCreateSchema, + invitationCreateSchemaDefaults, +} from '../invitation-create-schema'; + +describe('Invitation Create Schema', () => { + describe('default schema', () => { + describe.each([ + { input: 'user@example.com', shouldPass: true, description: 'simple email' }, + { input: 'test.user@domain.com', shouldPass: true, description: 'email with dots' }, + { input: 'user+tag@example.com', shouldPass: true, description: 'email with plus tag' }, + { input: 'user@sub.domain.com', shouldPass: true, description: 'email with subdomain' }, + { input: 'a@b.co', shouldPass: true, description: 'minimal valid email' }, + { + input: 'user-name@example.org', + shouldPass: true, + description: 'email with hyphen in local', + }, + { + input: 'user_name@example.org', + shouldPass: true, + description: 'email with underscore in local', + }, + ])('when email is "$input" ($description)', ({ input, shouldPass }) => { + it(`should ${shouldPass ? 'accept' : 'reject'}`, () => { + const result = invitationCreateSchemaDefaults.emailSchema.safeParse(input); + expect(result.success).toBe(shouldPass); + }); + }); + + describe.each([ + { input: '', shouldPass: false, description: 'empty string' }, + { input: ' ', shouldPass: false, description: 'whitespace only' }, + { input: 'notanemail', shouldPass: false, description: 'missing @ and domain' }, + { input: '@example.com', shouldPass: false, description: 'missing local part' }, + { input: 'user@', shouldPass: false, description: 'missing domain' }, + { input: 'user@.com', shouldPass: false, description: 'missing domain name' }, + { input: 'user @example.com', shouldPass: false, description: 'space in local part' }, + ])('when email is "$input" ($description)', ({ input, shouldPass }) => { + it(`should ${shouldPass ? 'accept' : 'reject'}`, () => { + const result = invitationCreateSchemaDefaults.emailSchema.safeParse(input); + expect(result.success).toBe(shouldPass); + }); + }); + + it('should return the default error message on validation failure', () => { + const result = invitationCreateSchemaDefaults.emailSchema.safeParse(''); + expect(result.success).toBe(false); + if (!result.success && result.error?.errors[0]) { + expect(result.error.errors[0].message).toBe('Please enter a valid email address'); + } + }); + + it('should have default maxEmails of 10', () => { + expect(invitationCreateSchemaDefaults.maxEmails).toBe(10); + }); + + it('should have default emailRegex', () => { + expect(invitationCreateSchemaDefaults.emailRegex).toBeInstanceOf(RegExp); + expect(invitationCreateSchemaDefaults.emailRegex.test('user@example.com')).toBe(true); + }); + }); + + describe('createInvitationCreateSchema factory', () => { + describe('with custom error message', () => { + it('should use custom default error message', () => { + const customMessage = 'Custom email validation error'; + const schema = createInvitationCreateSchema({}, customMessage); + const result = schema.emailSchema.safeParse(''); + + expect(result.success).toBe(false); + if (!result.success && result.error?.errors[0]) { + expect(result.error.errors[0].message).toBe(customMessage); + } + }); + + it('should use field-level error message over default', () => { + const fieldError = 'Field-level error'; + const defaultError = 'Default error'; + const schema = createInvitationCreateSchema( + { email: { errorMessage: fieldError } }, + defaultError, + ); + const result = schema.emailSchema.safeParse('invalid'); + + expect(result.success).toBe(false); + if (!result.success && result.error?.errors[0]) { + expect(result.error.errors[0].message).toBe(fieldError); + } + }); + }); + + describe('with custom regex', () => { + it('should accept emails matching the custom regex', () => { + const customRegex = /^[\w.+-]+@auth0\.com$/; + const schema = createInvitationCreateSchema({ + email: { + regex: customRegex, + errorMessage: 'Only @auth0.com emails allowed', + }, + }); + + expect(schema.emailSchema.safeParse('user@auth0.com').success).toBe(true); + expect(schema.emailSchema.safeParse('test.user@auth0.com').success).toBe(true); + }); + + it('should reject emails not matching the custom regex', () => { + const customRegex = /^[\w.+-]+@auth0\.com$/; + const schema = createInvitationCreateSchema({ + email: { + regex: customRegex, + errorMessage: 'Only @auth0.com emails allowed', + }, + }); + + expect(schema.emailSchema.safeParse('user@example.com').success).toBe(false); + expect(schema.emailSchema.safeParse('user@gmail.com').success).toBe(false); + }); + + it('should use custom error message with custom regex validation failure', () => { + const customRegex = /^[\w.+-]+@auth0\.com$/; + const customErrorMessage = 'Only @auth0.com emails allowed'; + const schema = createInvitationCreateSchema({ + email: { + regex: customRegex, + errorMessage: customErrorMessage, + }, + }); + const result = schema.emailSchema.safeParse('user@example.com'); + + expect(result.success).toBe(false); + if (!result.success && result.error?.errors[0]) { + expect(result.error.errors[0].message).toBe(customErrorMessage); + } + }); + }); + + describe('with custom maxEmails', () => { + it('should override the default maxEmails', () => { + const schema = createInvitationCreateSchema({ maxEmails: 5 }); + expect(schema.maxEmails).toBe(5); + }); + + it('should allow maxEmails of 1', () => { + const schema = createInvitationCreateSchema({ maxEmails: 1 }); + expect(schema.maxEmails).toBe(1); + }); + + it('should default to 10 when maxEmails is not provided', () => { + const schema = createInvitationCreateSchema({}); + expect(schema.maxEmails).toBe(10); + }); + }); + + describe('with empty options', () => { + it('should behave like the default schema', () => { + const schema = createInvitationCreateSchema({}); + + expect(schema.emailSchema.safeParse('user@example.com').success).toBe(true); + expect(schema.emailSchema.safeParse('').success).toBe(false); + expect(schema.maxEmails).toBe(10); + }); + }); + + describe('with undefined options', () => { + it('should behave like the default schema', () => { + const schema = createInvitationCreateSchema(undefined); + + expect(schema.emailSchema.safeParse('user@example.com').success).toBe(true); + expect(schema.emailSchema.safeParse('').success).toBe(false); + expect(schema.maxEmails).toBe(10); + }); + }); + + describe('with no arguments', () => { + it('should return defaults', () => { + const schema = createInvitationCreateSchema(); + + expect(schema.emailSchema.safeParse('user@example.com').success).toBe(true); + expect(schema.emailSchema.safeParse('').success).toBe(false); + expect(schema.maxEmails).toBe(10); + expect(schema.emailErrorMessage).toBe('Please enter a valid email address'); + }); + }); + }); + + describe('return value structure', () => { + it('should return emailSchema, emailRegex, emailErrorMessage, and maxEmails', () => { + const result = createInvitationCreateSchema(); + + expect(result).toHaveProperty('emailSchema'); + expect(result).toHaveProperty('emailRegex'); + expect(result).toHaveProperty('emailErrorMessage'); + expect(result).toHaveProperty('maxEmails'); + }); + + it('should return a Zod schema for emailSchema', () => { + const result = createInvitationCreateSchema(); + expect(result.emailSchema.safeParse).toBeDefined(); + expect(typeof result.emailSchema.safeParse).toBe('function'); + }); + }); + + describe('edge cases', () => { + it('should handle email field config with only regex (no errorMessage)', () => { + const schema = createInvitationCreateSchema({ + email: { regex: /^[\w.+-]+@company\.com$/ }, + }); + + // Should use the default error message since none was provided + expect(schema.emailErrorMessage).toBe('Please enter a valid email address'); + }); + + it('should handle email field config with only errorMessage (no regex)', () => { + const schema = createInvitationCreateSchema({ + email: { errorMessage: 'Custom error' }, + }); + + // Should still use the default regex + expect(schema.emailSchema.safeParse('user@example.com').success).toBe(true); + expect(schema.emailErrorMessage).toBe('Custom error'); + }); + }); +}); diff --git a/packages/core/src/schemas/my-organization/member-management/invitations/invitation-create-schema-types.ts b/packages/core/src/schemas/my-organization/member-management/invitations/invitation-create-schema-types.ts new file mode 100644 index 000000000..b115aa96d --- /dev/null +++ b/packages/core/src/schemas/my-organization/member-management/invitations/invitation-create-schema-types.ts @@ -0,0 +1,24 @@ +/** + * Invitation creation schema type definitions. + * @module invitation-create-schema-types + * @internal + */ + +/** + * Schema configuration for a single email field. + * @internal + */ +export interface EmailFieldConfig { + regex?: RegExp; + errorMessage?: string; +} + +/** + * Schema configuration for invitation creation form. + * Consumers can override validation rules for each field. + * @internal + */ +export interface InvitationCreateSchemas { + email?: EmailFieldConfig; + maxEmails?: number; +} diff --git a/packages/core/src/schemas/my-organization/member-management/invitations/invitation-create-schema.ts b/packages/core/src/schemas/my-organization/member-management/invitations/invitation-create-schema.ts new file mode 100644 index 000000000..557818a4e --- /dev/null +++ b/packages/core/src/schemas/my-organization/member-management/invitations/invitation-create-schema.ts @@ -0,0 +1,77 @@ +/** + * Invitation creation schema for form validation. + * @module invitation-create-schema + * @internal + */ + +import { z } from 'zod'; + +import { type InvitationCreateSchemas } from './invitation-create-schema-types'; + +/** Default email regex pattern. */ +const DEFAULT_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Default maximum number of email addresses allowed. */ +const DEFAULT_MAX_EMAILS = 10; + +/** + * Helper to merge schema field config with defaults. + * @param schema - Schema configuration + * @param field - Form field name + * @param defaultError - Default error message + * @returns The merged field configuration + * @internal + */ +const mergeFieldConfig = ( + schema: InvitationCreateSchemas | undefined, + field: 'email', + defaultError: string, +) => { + const fieldConfig = schema?.[field]; + return fieldConfig + ? { + ...fieldConfig, + errorMessage: fieldConfig.errorMessage || defaultError, + } + : { + errorMessage: defaultError, + }; +}; + +/** + * Creates a schema for invitation create form validation. + * @param options - Schema configuration options (consumers can override) + * @param defaultEmailError - Default error message for invalid email + * @returns Object containing Zod email schema, email regex, and maxEmails + */ +export const createInvitationCreateSchema = ( + options: InvitationCreateSchemas = {}, + defaultEmailError = 'Please enter a valid email address', +) => { + const emailConfig = mergeFieldConfig(options, 'email', defaultEmailError); + + const emailRegex = emailConfig.regex ?? DEFAULT_EMAIL_REGEX; + const emailErrorMessage = emailConfig.errorMessage ?? defaultEmailError; + const maxEmails = options.maxEmails ?? DEFAULT_MAX_EMAILS; + + const emailSchema = z.string().min(1, emailErrorMessage).regex(emailRegex, emailErrorMessage); + + return { + emailSchema, + emailRegex, + emailErrorMessage, + maxEmails, + }; +}; + +/** + * Default invitation create schema configuration. + */ +export const invitationCreateSchemaDefaults = createInvitationCreateSchema(); + +/** + * Type for a validated email value. + */ +export type InternalInvitationEmailValue = z.infer< + typeof invitationCreateSchemaDefaults.emailSchema +>; diff --git a/packages/core/src/schemas/my-organization/member-management/invitations/invitation-schema.ts b/packages/core/src/schemas/my-organization/member-management/invitations/invitation-schema.ts new file mode 100644 index 000000000..c5143b8c3 --- /dev/null +++ b/packages/core/src/schemas/my-organization/member-management/invitations/invitation-schema.ts @@ -0,0 +1,61 @@ +/** + * Invitation validation schemas. + * @module invitation-schema + * @internal + */ + +import { z } from 'zod'; + +/** + * Schema for organization invitation data. + * @internal + */ +export const invitationSchema = z.object({ + id: z.string(), + invitee: z.object({ + email: z.string().email(), + }), + inviter: z.object({ + name: z.string().optional(), + }), + roles: z.array(z.string()).optional(), + created_at: z.string().optional(), + expires_at: z.string().optional(), +}); + +/** + * Schema for invitation list response. + * @internal + */ +export const invitationListResponseSchema = z.object({ + invitations: z.array(invitationSchema), + total: z.number().optional(), + start: z.number().optional(), + limit: z.number().optional(), +}); + +/** + * Schema for creating invitation(s) + * @internal + */ +export const createInvitationSchema = z.object({ + invitees: z.array( + z.object({ + email: z.string().email(), + roles: z.array(z.string()).optional(), + }), + ), +}); + +/** + * Schema for revoking an invitation. + * @internal + */ +export const revokeInvitationSchema = z.object({ + invitation_id: z.string(), +}); + +export type Invitation = z.infer; +export type InvitationListResponse = z.infer; +export type CreateInvitationInput = z.infer; +export type RevokeInvitationInput = z.infer; diff --git a/packages/react/src/hooks/my-organization/use-config.ts b/packages/react/src/hooks/my-organization/use-config.ts index f3db4455f..01c57aa0e 100644 --- a/packages/react/src/hooks/my-organization/use-config.ts +++ b/packages/react/src/hooks/my-organization/use-config.ts @@ -58,5 +58,6 @@ export function useConfig(): UseConfigResult { filteredStrategies, shouldAllowDeletion, isConfigValid, + allowedRoles: [], }; } diff --git a/packages/react/src/hooks/shared/__tests__/use-checkpoint-pagination.test.ts b/packages/react/src/hooks/shared/__tests__/use-checkpoint-pagination.test.ts new file mode 100644 index 000000000..e38d795e3 --- /dev/null +++ b/packages/react/src/hooks/shared/__tests__/use-checkpoint-pagination.test.ts @@ -0,0 +1,235 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; + +import { useCheckpointPagination } from '@/hooks/shared/use-checkpoint-pagination'; +import type { CheckpointPaginationOptions } from '@/hooks/shared/use-checkpoint-pagination'; + +interface TestFilter { + roleId?: string; + searchQuery?: string; +} + +const renderPagination = (options?: CheckpointPaginationOptions) => + renderHook(() => useCheckpointPagination(options)); + +describe('useCheckpointPagination', () => { + describe('Initial State', () => { + it('should initialize with default values', () => { + const { result } = renderPagination(); + + expect(result.current.pageSize).toBe(10); + expect(result.current.currentPage).toBe(1); + expect(result.current.fromToken).toBeUndefined(); + expect(result.current.hasPreviousPage).toBe(false); + expect(result.current.sortConfig).toEqual({ key: null, direction: 'asc' }); + expect(result.current.filters).toEqual({}); + }); + + it('should initialize with custom defaults', () => { + const { result } = renderPagination({ + defaultPageSize: 25, + defaultSortConfig: { key: 'created_at', direction: 'desc' }, + defaultFilters: { roleId: 'role_admin' }, + }); + + expect(result.current.pageSize).toBe(25); + expect(result.current.sortConfig).toEqual({ key: 'created_at', direction: 'desc' }); + expect(result.current.filters).toEqual({ roleId: 'role_admin' }); + }); + }); + + describe('goToNextPage', () => { + it('should advance to the next page with a token', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + + expect(result.current.currentPage).toBe(2); + expect(result.current.fromToken).toBe('token_page2'); + expect(result.current.hasPreviousPage).toBe(true); + }); + + it('should track multiple page navigations', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + act(() => { + result.current.goToNextPage('token_page3'); + }); + + expect(result.current.currentPage).toBe(3); + expect(result.current.fromToken).toBe('token_page3'); + expect(result.current.hasPreviousPage).toBe(true); + }); + }); + + describe('goToPreviousPage', () => { + it('should go back to the previous page', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + act(() => { + result.current.goToPreviousPage(); + }); + + expect(result.current.currentPage).toBe(1); + expect(result.current.fromToken).toBeUndefined(); + expect(result.current.hasPreviousPage).toBe(false); + }); + + it('should navigate back through multiple pages correctly', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + act(() => { + result.current.goToNextPage('token_page3'); + }); + act(() => { + result.current.goToPreviousPage(); + }); + + expect(result.current.currentPage).toBe(2); + expect(result.current.fromToken).toBe('token_page2'); + expect(result.current.hasPreviousPage).toBe(true); + }); + + it('should not go below page 1', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToPreviousPage(); + }); + + expect(result.current.currentPage).toBe(1); + }); + }); + + describe('changePageSize', () => { + it('should update page size and reset pagination', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + act(() => { + result.current.changePageSize(25); + }); + + expect(result.current.pageSize).toBe(25); + expect(result.current.currentPage).toBe(1); + expect(result.current.fromToken).toBeUndefined(); + expect(result.current.hasPreviousPage).toBe(false); + }); + }); + + describe('changeSortConfig', () => { + it('should update sort config and reset pagination', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + act(() => { + result.current.changeSortConfig({ key: 'created_at', direction: 'desc' }); + }); + + expect(result.current.sortConfig).toEqual({ key: 'created_at', direction: 'desc' }); + expect(result.current.currentPage).toBe(1); + expect(result.current.fromToken).toBeUndefined(); + expect(result.current.hasPreviousPage).toBe(false); + }); + }); + + describe('changeFilters', () => { + it('should update filters with a direct value and reset pagination', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + act(() => { + result.current.changeFilters({ roleId: 'role_admin' }); + }); + + expect(result.current.filters).toEqual({ roleId: 'role_admin' }); + expect(result.current.currentPage).toBe(1); + expect(result.current.fromToken).toBeUndefined(); + }); + + it('should update filters with an updater function', () => { + const { result } = renderPagination({ + defaultFilters: { searchQuery: 'test' }, + }); + + act(() => { + result.current.changeFilters((prev) => ({ ...prev, roleId: 'role_member' })); + }); + + expect(result.current.filters).toEqual({ searchQuery: 'test', roleId: 'role_member' }); + }); + }); + + describe('reset', () => { + it('should reset all state to defaults', () => { + const { result } = renderPagination({ + defaultPageSize: 10, + defaultSortConfig: { key: null, direction: 'asc' }, + defaultFilters: {}, + }); + + act(() => { + result.current.goToNextPage('token_page2'); + result.current.changePageSize(50); + }); + act(() => { + result.current.changeSortConfig({ key: 'created_at', direction: 'desc' }); + }); + act(() => { + result.current.changeFilters({ roleId: 'role_admin' }); + }); + act(() => { + result.current.reset(); + }); + + expect(result.current.pageSize).toBe(10); + expect(result.current.currentPage).toBe(1); + expect(result.current.fromToken).toBeUndefined(); + expect(result.current.hasPreviousPage).toBe(false); + expect(result.current.sortConfig).toEqual({ key: null, direction: 'asc' }); + expect(result.current.filters).toEqual({}); + }); + + it('should reset to custom defaults when provided', () => { + const { result } = renderPagination({ + defaultPageSize: 25, + defaultSortConfig: { key: 'created_at', direction: 'desc' }, + defaultFilters: { roleId: 'role_admin' }, + }); + + act(() => { + result.current.changePageSize(50); + }); + act(() => { + result.current.changeSortConfig({ key: null, direction: 'asc' }); + }); + act(() => { + result.current.changeFilters({}); + }); + act(() => { + result.current.reset(); + }); + + expect(result.current.pageSize).toBe(25); + expect(result.current.sortConfig).toEqual({ key: 'created_at', direction: 'desc' }); + expect(result.current.filters).toEqual({ roleId: 'role_admin' }); + }); + }); +}); diff --git a/packages/react/src/hooks/shared/use-checkpoint-pagination.ts b/packages/react/src/hooks/shared/use-checkpoint-pagination.ts new file mode 100644 index 000000000..1d504d4e4 --- /dev/null +++ b/packages/react/src/hooks/shared/use-checkpoint-pagination.ts @@ -0,0 +1,131 @@ +import * as React from 'react'; + +interface SortConfig { + key: string | null; + direction: 'asc' | 'desc'; +} + +export interface CheckpointPaginationOptions { + defaultPageSize?: number; + defaultSortConfig?: SortConfig; + defaultFilters?: TFilter; +} + +export interface UseCheckpointPaginationResult { + pageSize: number; + fromToken: string | undefined; + sortConfig: SortConfig; + filters: TFilter; + currentPage: number; + hasPreviousPage: boolean; + goToNextPage: (nextToken: string) => void; + goToPreviousPage: () => void; + changePageSize: (pageSize: number) => void; + changeSortConfig: (sortConfig: SortConfig) => void; + changeFilters: (updater: TFilter | ((prev: TFilter) => TFilter)) => void; + reset: () => void; +} + +const DEFAULT_PAGE_SIZE = 10; +const DEFAULT_SORT_CONFIG: SortConfig = { key: null, direction: 'asc' }; + +/** + * Hook for checkpoint-based pagination with sort, filter, and auto-reset. + * @param options - Pagination configuration options. + * @returns Pagination state and control functions. + */ +export function useCheckpointPagination>( + options: CheckpointPaginationOptions = {}, +): UseCheckpointPaginationResult { + const { + defaultPageSize = DEFAULT_PAGE_SIZE, + defaultSortConfig = DEFAULT_SORT_CONFIG, + defaultFilters = {} as TFilter, + } = options; + + const [pageSize, setPageSize] = React.useState(defaultPageSize); + const [cursor, setCursor] = React.useState<{ + fromToken: string | undefined; + previousTokens: (string | undefined)[]; + currentPage: number; + }>({ + fromToken: undefined, + previousTokens: [], + currentPage: 1, + }); + const [sortConfig, setSortConfig] = React.useState(defaultSortConfig); + const [filters, setFilters] = React.useState(defaultFilters); + + const resetCursor = React.useCallback(() => { + setCursor({ fromToken: undefined, previousTokens: [], currentPage: 1 }); + }, []); + + const goToNextPage = React.useCallback((nextToken: string) => { + setCursor((prev) => ({ + previousTokens: [...prev.previousTokens, prev.fromToken], + fromToken: nextToken, + currentPage: prev.currentPage + 1, + })); + }, []); + + const goToPreviousPage = React.useCallback(() => { + setCursor((prev) => { + if (prev.previousTokens.length === 0) return prev; + + const newStack = [...prev.previousTokens]; + const prevToken = newStack.pop(); + + return { + previousTokens: newStack, + fromToken: prevToken, + currentPage: Math.max(1, prev.currentPage - 1), + }; + }); + }, []); + + const changePageSize = React.useCallback( + (newPageSize: number) => { + setPageSize(newPageSize); + resetCursor(); + }, + [resetCursor], + ); + + const changeSortConfig = React.useCallback( + (newSortConfig: SortConfig) => { + setSortConfig(newSortConfig); + resetCursor(); + }, + [resetCursor], + ); + + const changeFilters = React.useCallback( + (updater: TFilter | ((prev: TFilter) => TFilter)) => { + setFilters(updater); + resetCursor(); + }, + [resetCursor], + ); + + const reset = React.useCallback(() => { + setPageSize(defaultPageSize); + setSortConfig(defaultSortConfig); + setFilters(defaultFilters); + resetCursor(); + }, [defaultPageSize, defaultSortConfig, defaultFilters, resetCursor]); + + return { + pageSize, + currentPage: cursor.currentPage, + fromToken: cursor.fromToken, + hasPreviousPage: cursor.previousTokens.length > 0, + sortConfig, + filters, + goToNextPage, + goToPreviousPage, + changePageSize, + changeSortConfig, + changeFilters, + reset, + }; +} diff --git a/packages/react/src/internals/__mocks__/my-organization/config/config.mocks.ts b/packages/react/src/internals/__mocks__/my-organization/config/config.mocks.ts index dd682ce97..2b0f13b39 100644 --- a/packages/react/src/internals/__mocks__/my-organization/config/config.mocks.ts +++ b/packages/react/src/internals/__mocks__/my-organization/config/config.mocks.ts @@ -20,5 +20,6 @@ export const createMockUseConfig = (overrides?: Partial): MockUse }, fetchConfig: vi.fn(async () => undefined), filteredStrategies: [], + allowedRoles: [], ...overrides, }); diff --git a/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts index 102991ff4..9a78bfa91 100644 --- a/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts @@ -14,5 +14,6 @@ export const createMockUseConfig = (overrides?: Partial): MockUse }, fetchConfig: vi.fn(async () => undefined), filteredStrategies: [], + allowedRoles: [], ...overrides, }); diff --git a/packages/react/src/types/my-organization/config/config-types.ts b/packages/react/src/types/my-organization/config/config-types.ts index b305c8cd9..3b7a27cba 100644 --- a/packages/react/src/types/my-organization/config/config-types.ts +++ b/packages/react/src/types/my-organization/config/config-types.ts @@ -8,6 +8,13 @@ import type { IdpStrategy, } from '@auth0/universal-components-core'; +/** Role returned from organization configuration. */ +export interface ConfigRole { + id: string; + name: string; + description?: string; +} + /** useConfig hook result. */ export interface UseConfigResult { config: GetConfigurationResponseContent | null; @@ -16,4 +23,5 @@ export interface UseConfigResult { filteredStrategies: IdpStrategy[]; shouldAllowDeletion: boolean; isConfigValid: boolean; + allowedRoles: ConfigRole[]; }