@open-kingdom/shared-frontend-feature-user-management 0.0.2-9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.babelrc +12 -0
  2. package/README.md +7 -0
  3. package/dist/index.d.ts +5 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +538 -0
  6. package/dist/lib/components/invitations/AcceptInvitation.component.d.ts +7 -0
  7. package/dist/lib/components/invitations/AcceptInvitation.component.d.ts.map +1 -0
  8. package/dist/lib/components/invitations/InviteUserModal.component.d.ts +7 -0
  9. package/dist/lib/components/invitations/InviteUserModal.component.d.ts.map +1 -0
  10. package/dist/lib/components/invitations/index.d.ts +3 -0
  11. package/dist/lib/components/invitations/index.d.ts.map +1 -0
  12. package/dist/lib/components/shared/ConfirmDialog.component.d.ts +12 -0
  13. package/dist/lib/components/shared/ConfirmDialog.component.d.ts.map +1 -0
  14. package/dist/lib/components/shared/FormField.component.d.ts +10 -0
  15. package/dist/lib/components/shared/FormField.component.d.ts.map +1 -0
  16. package/dist/lib/components/shared/ModalOverlay.component.d.ts +9 -0
  17. package/dist/lib/components/shared/ModalOverlay.component.d.ts.map +1 -0
  18. package/dist/lib/components/shared/RoleBadge.component.d.ts +7 -0
  19. package/dist/lib/components/shared/RoleBadge.component.d.ts.map +1 -0
  20. package/dist/lib/components/shared/StatusCard.component.d.ts +10 -0
  21. package/dist/lib/components/shared/StatusCard.component.d.ts.map +1 -0
  22. package/dist/lib/components/users/UserList.component.d.ts +6 -0
  23. package/dist/lib/components/users/UserList.component.d.ts.map +1 -0
  24. package/dist/lib/styles.d.ts +8 -0
  25. package/dist/lib/styles.d.ts.map +1 -0
  26. package/dist/lib/types.d.ts +9 -0
  27. package/dist/lib/types.d.ts.map +1 -0
  28. package/jest.config.cts +14 -0
  29. package/package.json +27 -0
  30. package/src/index.ts +4 -0
  31. package/src/lib/components/invitations/AcceptInvitation.component.spec.tsx +154 -0
  32. package/src/lib/components/invitations/AcceptInvitation.component.tsx +197 -0
  33. package/src/lib/components/invitations/InviteUserModal.component.spec.tsx +79 -0
  34. package/src/lib/components/invitations/InviteUserModal.component.tsx +121 -0
  35. package/src/lib/components/invitations/index.ts +2 -0
  36. package/src/lib/components/shared/ConfirmDialog.component.spec.tsx +45 -0
  37. package/src/lib/components/shared/ConfirmDialog.component.tsx +58 -0
  38. package/src/lib/components/shared/FormField.component.spec.tsx +50 -0
  39. package/src/lib/components/shared/FormField.component.tsx +34 -0
  40. package/src/lib/components/shared/ModalOverlay.component.spec.tsx +81 -0
  41. package/src/lib/components/shared/ModalOverlay.component.tsx +45 -0
  42. package/src/lib/components/shared/RoleBadge.component.spec.tsx +20 -0
  43. package/src/lib/components/shared/RoleBadge.component.tsx +25 -0
  44. package/src/lib/components/shared/StatusCard.component.spec.tsx +44 -0
  45. package/src/lib/components/shared/StatusCard.component.tsx +47 -0
  46. package/src/lib/components/users/UserList.component.spec.tsx +216 -0
  47. package/src/lib/components/users/UserList.component.tsx +153 -0
  48. package/src/lib/styles.ts +19 -0
  49. package/src/lib/types.ts +9 -0
  50. package/tsconfig.json +13 -0
  51. package/tsconfig.lib.json +47 -0
  52. package/tsconfig.spec.json +27 -0
  53. package/vite.config.mts +58 -0
@@ -0,0 +1,6 @@
1
+ interface UserListProps {
2
+ currentUserId?: number;
3
+ }
4
+ export declare function UserList({ currentUserId }: UserListProps): import("react/jsx-runtime").JSX.Element;
5
+ export {};
6
+ //# sourceMappingURL=UserList.component.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UserList.component.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/users/UserList.component.tsx"],"names":[],"mappings":"AAmBA,UAAU,aAAa;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,wBAAgB,QAAQ,CAAC,EAAE,aAAa,EAAE,EAAE,aAAa,2CAiIxD"}
@@ -0,0 +1,8 @@
1
+ export declare const inputStyles = "w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100";
2
+ export declare const labelStyles = "block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1";
3
+ export declare const cardStyles = "max-w-md mx-auto mt-8 p-6 bg-white dark:bg-neutral-800 rounded-lg shadow-lg";
4
+ export declare const bodyTextStyles = "text-neutral-600 dark:text-neutral-400";
5
+ export declare const buttonPrimaryStyles = "rounded-md px-4 py-2 text-sm font-medium transition-colors bg-primary-500 text-white hover:bg-primary-600 disabled:opacity-50";
6
+ export declare const buttonSecondaryStyles = "rounded-md px-4 py-2 text-sm font-medium transition-colors border border-neutral-300 text-neutral-700 hover:bg-neutral-50 dark:border-neutral-600 dark:text-neutral-300 dark:hover:bg-neutral-700";
7
+ export declare const buttonDangerStyles = "rounded-md px-4 py-2 text-sm font-medium transition-colors bg-error-500 text-white hover:bg-error-600 disabled:opacity-50";
8
+ //# sourceMappingURL=styles.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"styles.d.ts","sourceRoot":"","sources":["../../src/lib/styles.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,sJAC6H,CAAC;AAEtJ,eAAO,MAAM,WAAW,0EACiD,CAAC;AAE1E,eAAO,MAAM,UAAU,gFACwD,CAAC;AAEhF,eAAO,MAAM,cAAc,2CAA2C,CAAC;AAKvE,eAAO,MAAM,mBAAmB,kIAA2F,CAAC;AAE5H,eAAO,MAAM,qBAAqB,sMAA+J,CAAC;AAElM,eAAO,MAAM,kBAAkB,8HAAuF,CAAC"}
@@ -0,0 +1,9 @@
1
+ export type Role = 'guest' | 'user' | 'admin';
2
+ export interface User {
3
+ id: number;
4
+ email: string;
5
+ firstName: string | null;
6
+ lastName: string | null;
7
+ role: Role;
8
+ }
9
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,IAAI,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC;AAE9C,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,IAAI,EAAE,IAAI,CAAC;CACZ"}
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ displayName: '@open-kingdom/feature-user-management',
3
+ preset: '../../../../jest.preset.js',
4
+ transform: {
5
+ '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
6
+ '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }],
7
+ },
8
+ transformIgnorePatterns: [
9
+ 'node_modules/(?!(@react-hookz/web|@ver0/deep-equal)/)',
10
+ ],
11
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
12
+ coverageDirectory: 'test-output/jest/coverage',
13
+ coveragePathIgnorePatterns: ['src/lib/types.ts'],
14
+ };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@open-kingdom/shared-frontend-feature-user-management",
3
+ "version": "0.0.2-9",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ "./package.json": "./package.json",
13
+ ".": {
14
+ "@open-kingdom/source": "./src/index.ts",
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "nx": {
21
+ "tags": [
22
+ "scope:shared",
23
+ "type:feature",
24
+ "environment:frontend"
25
+ ]
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { UserList } from './lib/components/users/UserList.component';
2
+ export { AcceptInvitation } from './lib/components/invitations';
3
+ export { StatusCard } from './lib/components/shared/StatusCard.component';
4
+ export type { User, Role } from './lib/types';
@@ -0,0 +1,154 @@
1
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2
+ import '@testing-library/jest-dom';
3
+ import { AcceptInvitation } from './AcceptInvitation.component';
4
+
5
+ const mockValidateQuery = jest.fn();
6
+ const mockAccept = jest.fn();
7
+ const mockAcceptState = jest.fn();
8
+
9
+ jest.mock('@open-kingdom/shared-frontend-data-access-api-client', () => ({
10
+ useInvitationsControllerValidateQuery: (...args: unknown[]) =>
11
+ mockValidateQuery(...args),
12
+ useInvitationsControllerAcceptMutation: () => [mockAccept, mockAcceptState()],
13
+ }));
14
+
15
+ describe('AcceptInvitation', () => {
16
+ beforeEach(() => {
17
+ mockAccept.mockReset();
18
+ mockAccept.mockReturnValue({ unwrap: () => Promise.resolve() });
19
+ mockAcceptState.mockReturnValue({ isLoading: false, isSuccess: false });
20
+ });
21
+
22
+ it('shows a loading message while checking the invitation', () => {
23
+ mockValidateQuery.mockReturnValue({
24
+ data: undefined,
25
+ isLoading: true,
26
+ error: null,
27
+ });
28
+ render(<AcceptInvitation token="test-token" />);
29
+ expect(screen.getByText('Validating invitation...')).toBeInTheDocument();
30
+ });
31
+
32
+ it('tells the user when something went wrong checking the invitation', () => {
33
+ mockValidateQuery.mockReturnValue({
34
+ data: undefined,
35
+ isLoading: false,
36
+ error: { status: 500 },
37
+ });
38
+ render(<AcceptInvitation token="bad-token" />);
39
+ expect(screen.getByText('Validation Failed')).toBeInTheDocument();
40
+ });
41
+
42
+ it('tells the user when the invitation link is invalid or expired', () => {
43
+ mockValidateQuery.mockReturnValue({
44
+ data: { valid: false },
45
+ isLoading: false,
46
+ error: null,
47
+ });
48
+ render(<AcceptInvitation token="bad-token" />);
49
+ expect(screen.getByText('Invalid Invitation')).toBeInTheDocument();
50
+ });
51
+
52
+ it('shows the registration form for a valid invitation', () => {
53
+ mockValidateQuery.mockReturnValue({
54
+ data: { valid: true, email: 'test@example.com', role: 'user' },
55
+ isLoading: false,
56
+ error: null,
57
+ });
58
+ render(<AcceptInvitation token="test-token" />);
59
+ expect(screen.getByText('Accept Invitation')).toBeInTheDocument();
60
+ expect(screen.getByLabelText(/first name/i)).toBeInTheDocument();
61
+ expect(screen.getByLabelText(/last name/i)).toBeInTheDocument();
62
+ expect(screen.getByLabelText(/^password/i)).toBeInTheDocument();
63
+ expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
64
+ });
65
+
66
+ it('shows the invited email and assigned role', () => {
67
+ mockValidateQuery.mockReturnValue({
68
+ data: { valid: true, email: 'test@example.com', role: 'admin' },
69
+ isLoading: false,
70
+ error: null,
71
+ });
72
+ render(<AcceptInvitation token="test-token" />);
73
+ expect(screen.getByText('admin')).toBeInTheDocument();
74
+ expect(screen.getByText('test@example.com')).toBeInTheDocument();
75
+ });
76
+
77
+ it('requires a password of at least 8 characters', async () => {
78
+ mockValidateQuery.mockReturnValue({
79
+ data: { valid: true, email: 'test@example.com', role: 'user' },
80
+ isLoading: false,
81
+ error: null,
82
+ });
83
+ render(<AcceptInvitation token="test-token" />);
84
+ fireEvent.change(screen.getByLabelText(/^password/i), {
85
+ target: { value: 'short' },
86
+ });
87
+ fireEvent.change(screen.getByLabelText(/confirm password/i), {
88
+ target: { value: 'short' },
89
+ });
90
+ fireEvent.click(screen.getByText('Create Account'));
91
+ await waitFor(() => {
92
+ expect(
93
+ screen.getByText('Password must be at least 8 characters')
94
+ ).toBeInTheDocument();
95
+ });
96
+ });
97
+
98
+ it('requires password and confirmation to match', async () => {
99
+ mockValidateQuery.mockReturnValue({
100
+ data: { valid: true, email: 'test@example.com', role: 'user' },
101
+ isLoading: false,
102
+ error: null,
103
+ });
104
+ render(<AcceptInvitation token="test-token" />);
105
+ fireEvent.change(screen.getByLabelText(/^password/i), {
106
+ target: { value: 'password123' },
107
+ });
108
+ fireEvent.change(screen.getByLabelText(/confirm password/i), {
109
+ target: { value: 'different123' },
110
+ });
111
+ fireEvent.click(screen.getByText('Create Account'));
112
+ await waitFor(() => {
113
+ expect(screen.getByText('Passwords do not match')).toBeInTheDocument();
114
+ });
115
+ });
116
+
117
+ it('submits the account details when the form is filled correctly', async () => {
118
+ mockValidateQuery.mockReturnValue({
119
+ data: { valid: true, email: 'test@example.com', role: 'user' },
120
+ isLoading: false,
121
+ error: null,
122
+ });
123
+ render(<AcceptInvitation token="good-token" />);
124
+ fireEvent.change(screen.getByLabelText(/^password/i), {
125
+ target: { value: 'password123' },
126
+ });
127
+ fireEvent.change(screen.getByLabelText(/confirm password/i), {
128
+ target: { value: 'password123' },
129
+ });
130
+ fireEvent.click(screen.getByText('Create Account'));
131
+ await waitFor(() => {
132
+ expect(mockAccept).toHaveBeenCalledWith({
133
+ acceptInvitationDto: {
134
+ token: 'good-token',
135
+ password: 'password123',
136
+ firstName: undefined,
137
+ lastName: undefined,
138
+ },
139
+ });
140
+ });
141
+ });
142
+
143
+ it('confirms account creation and links to login', () => {
144
+ mockValidateQuery.mockReturnValue({
145
+ data: { valid: true, email: 'test@example.com', role: 'user' },
146
+ isLoading: false,
147
+ error: null,
148
+ });
149
+ mockAcceptState.mockReturnValue({ isLoading: false, isSuccess: true });
150
+ render(<AcceptInvitation token="good-token" loginPath="/profile" />);
151
+ expect(screen.getByText('Account Created')).toBeInTheDocument();
152
+ expect(screen.getByText('Go to login')).toHaveAttribute('href', '/profile');
153
+ });
154
+ });
@@ -0,0 +1,197 @@
1
+ import { useForm } from 'react-hook-form';
2
+ import { zodResolver } from '@hookform/resolvers/zod';
3
+ import { z } from 'zod';
4
+ import {
5
+ useInvitationsControllerValidateQuery,
6
+ useInvitationsControllerAcceptMutation,
7
+ } from '@open-kingdom/shared-frontend-data-access-api-client';
8
+ import { StatusCard } from '../shared/StatusCard.component';
9
+ import { FormField } from '../shared/FormField.component';
10
+ import {
11
+ inputStyles,
12
+ buttonPrimaryStyles,
13
+ cardStyles,
14
+ bodyTextStyles,
15
+ } from '../../styles';
16
+
17
+ const acceptSchema = z
18
+ .object({
19
+ firstName: z.string().optional(),
20
+ lastName: z.string().optional(),
21
+ password: z.string().min(8, 'Password must be at least 8 characters'),
22
+ confirmPassword: z.string(),
23
+ })
24
+ .refine((data) => data.password === data.confirmPassword, {
25
+ message: 'Passwords do not match',
26
+ path: ['confirmPassword'],
27
+ });
28
+
29
+ type AcceptFormValues = z.infer<typeof acceptSchema>;
30
+
31
+ interface AcceptInvitationProps {
32
+ token: string;
33
+ loginPath?: string;
34
+ }
35
+
36
+ export function AcceptInvitation({ token, loginPath }: AcceptInvitationProps) {
37
+ const {
38
+ data: validation,
39
+ isLoading: isValidating,
40
+ error: validationError,
41
+ } = useInvitationsControllerValidateQuery({ token });
42
+
43
+ const [accept, { isLoading, isSuccess }] =
44
+ useInvitationsControllerAcceptMutation();
45
+
46
+ const {
47
+ register,
48
+ handleSubmit,
49
+ formState: { errors },
50
+ } = useForm<AcceptFormValues>({
51
+ resolver: zodResolver(acceptSchema),
52
+ defaultValues: {
53
+ firstName: '',
54
+ lastName: '',
55
+ password: '',
56
+ confirmPassword: '',
57
+ },
58
+ });
59
+
60
+ const email = validation?.email ?? '';
61
+ const role = validation?.role ?? 'user';
62
+
63
+ const onSubmit = async (data: AcceptFormValues) => {
64
+ try {
65
+ await accept({
66
+ acceptInvitationDto: {
67
+ token,
68
+ password: data.password,
69
+ firstName: data.firstName || undefined,
70
+ lastName: data.lastName || undefined,
71
+ },
72
+ }).unwrap();
73
+ } catch {
74
+ // Error notification handled by RTK error middleware
75
+ }
76
+ };
77
+
78
+ if (isSuccess) {
79
+ return (
80
+ <StatusCard
81
+ variant="success"
82
+ title="Account Created"
83
+ message="Your account has been created successfully. You can now log in with your email and password."
84
+ >
85
+ {loginPath && (
86
+ <a
87
+ href={loginPath}
88
+ data-testid="accept-login-link"
89
+ className="mt-4 inline-block text-sm font-medium text-primary-600 hover:underline dark:text-primary-400"
90
+ >
91
+ Go to login
92
+ </a>
93
+ )}
94
+ </StatusCard>
95
+ );
96
+ }
97
+
98
+ if (isValidating) {
99
+ return <StatusCard variant="loading" message="Validating invitation..." />;
100
+ }
101
+
102
+ if (validationError) {
103
+ return (
104
+ <StatusCard
105
+ variant="error"
106
+ title="Validation Failed"
107
+ message="Unable to validate this invitation. Please check your connection and try again."
108
+ />
109
+ );
110
+ }
111
+
112
+ if (!validation?.valid) {
113
+ return (
114
+ <StatusCard
115
+ variant="error"
116
+ title="Invalid Invitation"
117
+ message="This invitation link is invalid or has expired. Please contact the person who invited you for a new link."
118
+ />
119
+ );
120
+ }
121
+
122
+ return (
123
+ <div className={cardStyles}>
124
+ <h2
125
+ data-testid="accept-heading"
126
+ className="text-xl font-bold text-neutral-900 dark:text-neutral-100"
127
+ >
128
+ Accept Invitation
129
+ </h2>
130
+ <p className={`mt-1 text-sm ${bodyTextStyles}`}>
131
+ You've been invited as <strong data-testid="accept-role">{role}</strong>{' '}
132
+ with email <strong data-testid="accept-email">{email}</strong>
133
+ </p>
134
+
135
+ <form onSubmit={handleSubmit(onSubmit)} className="mt-4 space-y-4">
136
+ <FormField label="First Name" htmlFor="accept-firstName">
137
+ <input
138
+ id="accept-firstName"
139
+ data-testid="accept-first-name-input"
140
+ type="text"
141
+ placeholder="John"
142
+ className={inputStyles}
143
+ {...register('firstName')}
144
+ />
145
+ </FormField>
146
+ <FormField label="Last Name" htmlFor="accept-lastName">
147
+ <input
148
+ id="accept-lastName"
149
+ data-testid="accept-last-name-input"
150
+ type="text"
151
+ placeholder="Doe"
152
+ className={inputStyles}
153
+ {...register('lastName')}
154
+ />
155
+ </FormField>
156
+ <FormField
157
+ label="Password"
158
+ htmlFor="accept-password"
159
+ required
160
+ error={errors.password?.message}
161
+ >
162
+ <input
163
+ id="accept-password"
164
+ data-testid="accept-password-input"
165
+ type="password"
166
+ placeholder="Min. 8 characters"
167
+ className={inputStyles}
168
+ {...register('password')}
169
+ />
170
+ </FormField>
171
+ <FormField
172
+ label="Confirm Password"
173
+ htmlFor="accept-confirmPassword"
174
+ required
175
+ error={errors.confirmPassword?.message}
176
+ >
177
+ <input
178
+ id="accept-confirmPassword"
179
+ data-testid="accept-confirm-password-input"
180
+ type="password"
181
+ placeholder="Repeat password"
182
+ className={inputStyles}
183
+ {...register('confirmPassword')}
184
+ />
185
+ </FormField>
186
+ <button
187
+ type="submit"
188
+ data-testid="accept-submit-btn"
189
+ disabled={isLoading}
190
+ className={`w-full ${buttonPrimaryStyles}`}
191
+ >
192
+ {isLoading ? 'Creating account...' : 'Create Account'}
193
+ </button>
194
+ </form>
195
+ </div>
196
+ );
197
+ }
@@ -0,0 +1,79 @@
1
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2
+ import '@testing-library/jest-dom';
3
+ import { Provider } from 'react-redux';
4
+ import { configureStore } from '@reduxjs/toolkit';
5
+ import { InviteUserModal } from './InviteUserModal.component';
6
+
7
+ const mockInvite = jest.fn();
8
+
9
+ jest.mock('@open-kingdom/shared-frontend-data-access-api-client', () => ({
10
+ useInvitationsControllerInviteMutation: () => [
11
+ mockInvite,
12
+ { isLoading: false },
13
+ ],
14
+ }));
15
+
16
+ jest.mock('@open-kingdom/shared-frontend-data-access-notifications', () => ({
17
+ showSuccessNotification: jest.fn((msg: string) => ({
18
+ type: 'notify',
19
+ payload: msg,
20
+ })),
21
+ }));
22
+
23
+ const store = configureStore({ reducer: {} });
24
+
25
+ function renderWithProviders(ui: React.ReactElement) {
26
+ return render(<Provider store={store}>{ui}</Provider>);
27
+ }
28
+
29
+ describe('InviteUserModal', () => {
30
+ beforeEach(() => {
31
+ mockInvite.mockReset();
32
+ mockInvite.mockReturnValue({ unwrap: () => Promise.resolve() });
33
+ });
34
+
35
+ it('shows the invitation form when opened', () => {
36
+ renderWithProviders(<InviteUserModal isOpen={true} onClose={jest.fn()} />);
37
+ expect(screen.getByText('Invite User')).toBeInTheDocument();
38
+ expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
39
+ expect(screen.getByLabelText(/role/i)).toBeInTheDocument();
40
+ });
41
+
42
+ it('hides the form when closed', () => {
43
+ renderWithProviders(<InviteUserModal isOpen={false} onClose={jest.fn()} />);
44
+ expect(screen.queryByText('Invite User')).not.toBeInTheDocument();
45
+ });
46
+
47
+ it('sends the invitation with the entered email and role', async () => {
48
+ const onClose = jest.fn();
49
+ renderWithProviders(<InviteUserModal isOpen={true} onClose={onClose} />);
50
+ fireEvent.change(screen.getByLabelText(/email/i), {
51
+ target: { value: 'user@example.com' },
52
+ });
53
+ fireEvent.click(screen.getByText('Send Invitation'));
54
+ await waitFor(() => {
55
+ expect(mockInvite).toHaveBeenCalledWith({
56
+ inviteUserDto: { email: 'user@example.com', role: 'guest' },
57
+ });
58
+ });
59
+ });
60
+
61
+ it('closes after sending the invitation', async () => {
62
+ const onClose = jest.fn();
63
+ renderWithProviders(<InviteUserModal isOpen={true} onClose={onClose} />);
64
+ fireEvent.change(screen.getByLabelText(/email/i), {
65
+ target: { value: 'user@example.com' },
66
+ });
67
+ fireEvent.click(screen.getByText('Send Invitation'));
68
+ await waitFor(() => {
69
+ expect(onClose).toHaveBeenCalled();
70
+ });
71
+ });
72
+
73
+ it('closes when clicking Cancel', () => {
74
+ const onClose = jest.fn();
75
+ renderWithProviders(<InviteUserModal isOpen={true} onClose={onClose} />);
76
+ fireEvent.click(screen.getByText('Cancel'));
77
+ expect(onClose).toHaveBeenCalled();
78
+ });
79
+ });
@@ -0,0 +1,121 @@
1
+ import { useForm } from 'react-hook-form';
2
+ import { zodResolver } from '@hookform/resolvers/zod';
3
+ import { z } from 'zod';
4
+ import { useDispatch } from 'react-redux';
5
+ import { useInvitationsControllerInviteMutation } from '@open-kingdom/shared-frontend-data-access-api-client';
6
+ import { showSuccessNotification } from '@open-kingdom/shared-frontend-data-access-notifications';
7
+ import { ModalOverlay } from '../shared/ModalOverlay.component';
8
+ import { FormField } from '../shared/FormField.component';
9
+ import {
10
+ inputStyles,
11
+ buttonPrimaryStyles,
12
+ buttonSecondaryStyles,
13
+ } from '../../styles';
14
+
15
+ const inviteSchema = z.object({
16
+ email: z.string().email('Invalid email address'),
17
+ role: z.enum(['guest', 'user', 'admin']),
18
+ });
19
+
20
+ type InviteFormValues = z.infer<typeof inviteSchema>;
21
+
22
+ interface InviteUserModalProps {
23
+ isOpen: boolean;
24
+ onClose: () => void;
25
+ }
26
+
27
+ export function InviteUserModal({ isOpen, onClose }: InviteUserModalProps) {
28
+ const dispatch = useDispatch();
29
+ const [invite, { isLoading }] = useInvitationsControllerInviteMutation();
30
+
31
+ const {
32
+ register,
33
+ handleSubmit,
34
+ reset,
35
+ formState: { errors },
36
+ } = useForm<InviteFormValues>({
37
+ resolver: zodResolver(inviteSchema),
38
+ defaultValues: { email: '', role: 'guest' },
39
+ });
40
+
41
+ const titleId = 'invite-modal-title';
42
+
43
+ const handleClose = () => {
44
+ reset();
45
+ onClose();
46
+ };
47
+
48
+ const onSubmit = async (data: InviteFormValues) => {
49
+ try {
50
+ await invite({ inviteUserDto: data }).unwrap();
51
+ dispatch(showSuccessNotification('Invitation sent successfully'));
52
+ reset();
53
+ onClose();
54
+ } catch {
55
+ // Error notification handled by RTK error middleware
56
+ }
57
+ };
58
+
59
+ return (
60
+ <ModalOverlay
61
+ isOpen={isOpen}
62
+ onClose={handleClose}
63
+ ariaLabelledBy={titleId}
64
+ >
65
+ <h3
66
+ id={titleId}
67
+ className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"
68
+ >
69
+ Invite User
70
+ </h3>
71
+ <form onSubmit={handleSubmit(onSubmit)} className="mt-4 space-y-4">
72
+ <FormField
73
+ label="Email"
74
+ htmlFor="invite-email"
75
+ required
76
+ error={errors.email?.message}
77
+ >
78
+ <input
79
+ id="invite-email"
80
+ data-testid="invite-email-input"
81
+ type="email"
82
+ placeholder="user@example.com"
83
+ className={inputStyles}
84
+ {...register('email')}
85
+ />
86
+ </FormField>
87
+ <FormField label="Role" htmlFor="invite-role">
88
+ <select
89
+ id="invite-role"
90
+ data-testid="invite-role-select"
91
+ className={inputStyles}
92
+ {...register('role')}
93
+ >
94
+ <option value="guest">Guest</option>
95
+ <option value="user">User</option>
96
+ <option value="admin">Admin</option>
97
+ </select>
98
+ </FormField>
99
+ <div className="flex justify-end gap-3">
100
+ <button
101
+ type="button"
102
+ data-testid="invite-cancel-btn"
103
+ onClick={handleClose}
104
+ disabled={isLoading}
105
+ className={buttonSecondaryStyles}
106
+ >
107
+ Cancel
108
+ </button>
109
+ <button
110
+ type="submit"
111
+ data-testid="invite-submit-btn"
112
+ disabled={isLoading}
113
+ className={buttonPrimaryStyles}
114
+ >
115
+ {isLoading ? 'Sending...' : 'Send Invitation'}
116
+ </button>
117
+ </div>
118
+ </form>
119
+ </ModalOverlay>
120
+ );
121
+ }
@@ -0,0 +1,2 @@
1
+ export { InviteUserModal } from './InviteUserModal.component';
2
+ export { AcceptInvitation } from './AcceptInvitation.component';