@micha.bigler/ui-core-micha 3.9.0 → 3.10.0
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.
- package/CHANGELOG.md +8 -0
- package/README.md +16 -0
- package/WORK_ORDERS.md +3 -0
- package/dist/components/UserListComponent.js +7 -1
- package/dist/index.js +2 -0
- package/dist/testing/checkKitIntegration.js +201 -0
- package/package.json +1 -1
- package/src/components/UserListComponent.jsx +7 -1
- package/src/index.js +3 -0
- package/src/testing/checkKitIntegration.js +233 -0
- package/tests/UserListComponent.test.jsx +78 -1
- package/tests/checkKitIntegration.test.jsx +219 -0
- package/work-orders/UCM-AUTH-11.md +185 -0
- package/work-orders/UCM-TEST-1.md +304 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
5
5
|
|
|
6
6
|
const authApi = vi.hoisted(() => ({
|
|
7
7
|
fetchUsersList: vi.fn(),
|
|
@@ -37,6 +37,22 @@ import { UserListComponent } from '../src/components/UserListComponent';
|
|
|
37
37
|
|
|
38
38
|
const currentUser = { id: 99, is_superuser: true, role: 'admin' };
|
|
39
39
|
const user = { id: 1, email: 'person@example.com', role: 'student' };
|
|
40
|
+
const ownUser = { id: 99, email: 'admin@example.com', role: 'admin' };
|
|
41
|
+
|
|
42
|
+
function renderDeleteList(props = {}) {
|
|
43
|
+
return render(
|
|
44
|
+
<UserListComponent
|
|
45
|
+
currentUser={currentUser}
|
|
46
|
+
showNewColumn={false}
|
|
47
|
+
showSuccessfulLoginColumn={false}
|
|
48
|
+
{...props}
|
|
49
|
+
/>,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getRowByEmail(email) {
|
|
54
|
+
return screen.getByRole('cell', { name: email }).closest('tr');
|
|
55
|
+
}
|
|
40
56
|
|
|
41
57
|
function renderUserList() {
|
|
42
58
|
return render(
|
|
@@ -90,3 +106,64 @@ describe('UserListComponent role updates', () => {
|
|
|
90
106
|
expect(authApi.fetchUsersList).toHaveBeenCalledTimes(1);
|
|
91
107
|
});
|
|
92
108
|
});
|
|
109
|
+
|
|
110
|
+
describe('UserListComponent delete actions', () => {
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
vi.clearAllMocks();
|
|
113
|
+
authApi.fetchUsersList.mockResolvedValue([ownUser, user]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
cleanup();
|
|
118
|
+
vi.restoreAllMocks();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('does not offer a superuser deletion of their own row', async () => {
|
|
122
|
+
renderDeleteList();
|
|
123
|
+
|
|
124
|
+
await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
|
|
125
|
+
|
|
126
|
+
expect(within(getRowByEmail(ownUser.email)).queryByRole('button', { name: 'Delete' })).toBeNull();
|
|
127
|
+
expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('does not offer an admin deletion of their own row', async () => {
|
|
131
|
+
renderDeleteList({ currentUser: { id: 99, role: 'admin' } });
|
|
132
|
+
|
|
133
|
+
await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
|
|
134
|
+
|
|
135
|
+
expect(within(getRowByEmail(ownUser.email)).queryByRole('button', { name: 'Delete' })).toBeNull();
|
|
136
|
+
expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('does not restore the own-row delete action when canDeleteUser returns true', async () => {
|
|
140
|
+
renderDeleteList({ canDeleteUser: () => true });
|
|
141
|
+
|
|
142
|
+
await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
|
|
143
|
+
|
|
144
|
+
expect(within(getRowByEmail(ownUser.email)).queryByRole('button', { name: 'Delete' })).toBeNull();
|
|
145
|
+
expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('keeps unrelated rows and disabled delete actions when currentUser is not loaded', async () => {
|
|
149
|
+
authApi.fetchUsersList.mockResolvedValue([user]);
|
|
150
|
+
renderDeleteList({ currentUser: null });
|
|
151
|
+
|
|
152
|
+
await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
|
|
153
|
+
|
|
154
|
+
expect(screen.getByRole('button', { name: 'Delete' }).disabled).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('matches the own row even when the row id and current-user id have different types', async () => {
|
|
158
|
+
// The backend row's id can come back as a string while currentUser.id is a
|
|
159
|
+
// number (or vice versa) -- isOwnRow must compare by value, not by strict
|
|
160
|
+
// type, or this exact mismatch would silently show the button again.
|
|
161
|
+
authApi.fetchUsersList.mockResolvedValue([{ ...ownUser, id: String(ownUser.id) }, user]);
|
|
162
|
+
renderDeleteList();
|
|
163
|
+
|
|
164
|
+
await waitFor(() => expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1));
|
|
165
|
+
|
|
166
|
+
expect(within(getRowByEmail(ownUser.email)).queryByRole('button', { name: 'Delete' })).toBeNull();
|
|
167
|
+
expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import i18next from 'i18next';
|
|
5
|
+
import { I18nextProvider } from 'react-i18next';
|
|
6
|
+
|
|
7
|
+
import apiClient from '../src/auth/apiClient';
|
|
8
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
9
|
+
import { checkKitIntegration } from '../src/testing/checkKitIntegration';
|
|
10
|
+
import { uiCoreTranslations } from '../src/i18n/uiCoreTranslations';
|
|
11
|
+
import { LoginPage } from '../src/pages/LoginPage';
|
|
12
|
+
|
|
13
|
+
const languages = ['de', 'en', 'fr', 'sw'];
|
|
14
|
+
const authMethods = {
|
|
15
|
+
password_login: true,
|
|
16
|
+
password_reset: false,
|
|
17
|
+
signup: false,
|
|
18
|
+
signup_modes: [],
|
|
19
|
+
social_login: false,
|
|
20
|
+
passkey_login: false,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function resources() {
|
|
24
|
+
return Object.fromEntries(languages.map((language) => [language, {
|
|
25
|
+
translation: Object.fromEntries(
|
|
26
|
+
Object.entries(uiCoreTranslations).map(([key, values]) => [key, values[language]]),
|
|
27
|
+
),
|
|
28
|
+
}]));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeWrapper(instance, authMethodsOverride = authMethods) {
|
|
32
|
+
return function FixtureWrapper({ children }) {
|
|
33
|
+
return (
|
|
34
|
+
<I18nextProvider i18n={instance}>
|
|
35
|
+
<AuthContext.Provider value={{
|
|
36
|
+
user: null,
|
|
37
|
+
loading: false,
|
|
38
|
+
login: vi.fn(),
|
|
39
|
+
authMethods: authMethodsOverride,
|
|
40
|
+
}}>
|
|
41
|
+
{children}
|
|
42
|
+
</AuthContext.Provider>
|
|
43
|
+
</I18nextProvider>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
50
|
+
await i18next.init({
|
|
51
|
+
lng: 'en',
|
|
52
|
+
fallbackLng: false,
|
|
53
|
+
supportedLngs: languages,
|
|
54
|
+
resources: resources(),
|
|
55
|
+
keySeparator: false,
|
|
56
|
+
nsSeparator: false,
|
|
57
|
+
interpolation: { escapeValue: false },
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(async () => {
|
|
62
|
+
await i18next.changeLanguage('en');
|
|
63
|
+
for (const language of languages) {
|
|
64
|
+
i18next.removeResourceBundle(language, 'translation');
|
|
65
|
+
i18next.addResourceBundle(language, 'translation', resources()[language].translation, true, true);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('checkKitIntegration', () => {
|
|
70
|
+
it('passes for a correctly wired app fixture', async () => {
|
|
71
|
+
const { findings } = await checkKitIntegration({
|
|
72
|
+
i18n: i18next,
|
|
73
|
+
wrapper: makeWrapper(i18next),
|
|
74
|
+
pages: [LoginPage],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(findings).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('does not flag a legitimate short lowercase translated word as a raw backend code', async () => {
|
|
81
|
+
// Auth.LOGIN_OR's German value is literally "oder" -- all-lowercase, no
|
|
82
|
+
// spaces, length 4 -- the exact shape a naive raw-code heuristic would
|
|
83
|
+
// false-positive on. Render LoginForm's sign-up/forgot-password divider
|
|
84
|
+
// (onSignUp truthy) in German to actually exercise that rendered text.
|
|
85
|
+
await i18next.changeLanguage('de');
|
|
86
|
+
const { findings } = await checkKitIntegration({
|
|
87
|
+
i18n: i18next,
|
|
88
|
+
wrapper: makeWrapper(i18next, { ...authMethods, signup: true, signup_modes: ['self_signup_open'] }),
|
|
89
|
+
pages: [LoginPage],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(findings).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('reports one catalogue finding for one missing language key', async () => {
|
|
96
|
+
const key = 'Auth.field.email.invalid';
|
|
97
|
+
const french = { ...resources().fr.translation };
|
|
98
|
+
delete french[key];
|
|
99
|
+
i18next.removeResourceBundle('fr', 'translation');
|
|
100
|
+
i18next.addResourceBundle('fr', 'translation', french, true, true);
|
|
101
|
+
|
|
102
|
+
const { findings } = await checkKitIntegration({
|
|
103
|
+
i18n: i18next,
|
|
104
|
+
wrapper: makeWrapper(i18next),
|
|
105
|
+
pages: [],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(findings).toHaveLength(1);
|
|
109
|
+
expect(findings[0]).toMatchObject({ check: 'catalogue' });
|
|
110
|
+
expect(findings[0].reason).toContain(`${key} for language fr`);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('still reports a missing key when the consumer app configures a fallback language', async () => {
|
|
114
|
+
// A consumer typically sets fallbackLng (e.g. "en"), not "false" like the
|
|
115
|
+
// fixture above. Without forcing fallbackLng: false per-call, i18next's
|
|
116
|
+
// exists() would resolve the missing "fr" key through the fallback and
|
|
117
|
+
// read as present, masking the real gap this check exists to catch.
|
|
118
|
+
const key = 'Auth.field.email.invalid';
|
|
119
|
+
const french = { ...resources().fr.translation };
|
|
120
|
+
delete french[key];
|
|
121
|
+
i18next.removeResourceBundle('fr', 'translation');
|
|
122
|
+
i18next.addResourceBundle('fr', 'translation', french, true, true);
|
|
123
|
+
i18next.options.fallbackLng = 'en';
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const { findings } = await checkKitIntegration({
|
|
127
|
+
i18n: i18next,
|
|
128
|
+
wrapper: makeWrapper(i18next),
|
|
129
|
+
pages: [],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(findings).toHaveLength(1);
|
|
133
|
+
expect(findings[0]).toMatchObject({ check: 'catalogue' });
|
|
134
|
+
expect(findings[0].reason).toContain(`${key} for language fr`);
|
|
135
|
+
} finally {
|
|
136
|
+
i18next.options.fallbackLng = false;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('reports when the app passes a separate i18next instance', async () => {
|
|
141
|
+
const separate = i18next.createInstance();
|
|
142
|
+
await separate.init({
|
|
143
|
+
lng: 'en',
|
|
144
|
+
fallbackLng: false,
|
|
145
|
+
supportedLngs: languages,
|
|
146
|
+
resources: resources(),
|
|
147
|
+
keySeparator: false,
|
|
148
|
+
nsSeparator: false,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const { findings } = await checkKitIntegration({
|
|
152
|
+
i18n: separate,
|
|
153
|
+
wrapper: makeWrapper(separate),
|
|
154
|
+
pages: [],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(findings.some(({ check }) => check === 'instance')).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('reports a page when the consumer wrapper throws', async () => {
|
|
161
|
+
function ThrowingWrapper() {
|
|
162
|
+
throw new Error('provider exploded');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const before = apiClient.defaults.adapter;
|
|
166
|
+
const { findings } = await checkKitIntegration({
|
|
167
|
+
i18n: i18next,
|
|
168
|
+
wrapper: ThrowingWrapper,
|
|
169
|
+
pages: [LoginPage],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(findings.some(({ check, reason }) => check === 'render' && reason.includes('LoginPage'))).toBe(true);
|
|
173
|
+
expect(apiClient.defaults.adapter).toBe(before);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('passes the exact invalid-email resolver regression through the real login page', async () => {
|
|
177
|
+
const { findings } = await checkKitIntegration({
|
|
178
|
+
i18n: i18next,
|
|
179
|
+
wrapper: makeWrapper(i18next),
|
|
180
|
+
pages: [],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(findings).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('restores the transport adapter after a successful check', async () => {
|
|
187
|
+
const before = apiClient.defaults.adapter;
|
|
188
|
+
await checkKitIntegration({
|
|
189
|
+
i18n: i18next,
|
|
190
|
+
wrapper: makeWrapper(i18next),
|
|
191
|
+
pages: [],
|
|
192
|
+
});
|
|
193
|
+
expect(apiClient.defaults.adapter).toBe(before);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('no longer intercepts a real request once the check has finished', async () => {
|
|
197
|
+
// Proves the restore is functional, not just reference-equal: a request
|
|
198
|
+
// made AFTER the check completes must reach the app's own adapter, not
|
|
199
|
+
// the check's stub (which would reject it as "blocked" or fake a login
|
|
200
|
+
// failure for the allauth login path).
|
|
201
|
+
const sentinel = vi.fn().mockResolvedValue({ data: 'real-response', status: 200, headers: {}, config: {} });
|
|
202
|
+
const before = apiClient.defaults.adapter;
|
|
203
|
+
apiClient.defaults.adapter = sentinel;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await checkKitIntegration({
|
|
207
|
+
i18n: i18next,
|
|
208
|
+
wrapper: makeWrapper(i18next),
|
|
209
|
+
pages: [],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const response = await apiClient.get('/api/some/path');
|
|
213
|
+
expect(sentinel).toHaveBeenCalled();
|
|
214
|
+
expect(response.data).toBe('real-response');
|
|
215
|
+
} finally {
|
|
216
|
+
apiClient.defaults.adapter = before;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# UCM-AUTH-11 — `UserListComponent` offers an admin the delete action on their own row
|
|
2
|
+
|
|
3
|
+
# A. Envelope — authored by the Expertenchat
|
|
4
|
+
|
|
5
|
+
## Goal & expected outcome
|
|
6
|
+
|
|
7
|
+
- Goal: the user list never offers anyone the deletion of their own account.
|
|
8
|
+
- Expected outcome:
|
|
9
|
+
- In `UserListComponent`, the row of the signed-in user renders **no** delete button — not a
|
|
10
|
+
disabled one, none at all. Every other row behaves exactly as today (enabled or disabled per
|
|
11
|
+
`canDeleteUser` / the default).
|
|
12
|
+
- This holds for every consumer, **including one that passes its own `canDeleteUser`**
|
|
13
|
+
(`AccountPage` forwards it as `userListCanDeleteUser`): a prop that returns true for the own row
|
|
14
|
+
does not bring the button back.
|
|
15
|
+
- Released; the Envelope's done-state is the release (consumer bumps are separate, see Risks).
|
|
16
|
+
|
|
17
|
+
## Context — measured 2026-09-30 against `main` at 3.9.0
|
|
18
|
+
|
|
19
|
+
- **Operator decision 2026-09-30 (from django-core-micha `DCM-AUTH-4`, dropped there): UI-only.**
|
|
20
|
+
The backend keeps allowing an admin's generic self-delete (`BaseUserViewSet.destroy()` refuses
|
|
21
|
+
only a non-admin's self-delete since dcm 2.44.1); the list simply stops offering it.
|
|
22
|
+
- `src/components/UserListComponent.jsx`:
|
|
23
|
+
- `defaultCanEdit` (~l.128) returns true for `currentUser.is_superuser` and for role `admin` on
|
|
24
|
+
every row, the own row included (only the `teacher` branch excludes self).
|
|
25
|
+
- `canDelete` (~l.151) returns `canDeleteUser(...)` when that prop is a function, otherwise
|
|
26
|
+
`canEdit(targetUser)`.
|
|
27
|
+
- The delete button (~l.438-452) renders whenever `showDeleteAction` is true and is only
|
|
28
|
+
`disabled={!canDelete(row)}` — so a row that may not be deleted still shows a disabled button.
|
|
29
|
+
- `src/pages/AccountPage.jsx` forwards `userListShowDeleteAction` (default true) and
|
|
30
|
+
`userListCanDeleteUser` (default null).
|
|
31
|
+
- Consumers measured: kerzenziehen and reimbursements render `AccountPage` without these props, so
|
|
32
|
+
an admin there sees an enabled delete button on their own row today. jg-ferien passes its own
|
|
33
|
+
`userListCanDeleteUser`, which already returns false for the own row (the button shows disabled).
|
|
34
|
+
|
|
35
|
+
## Scope + non-goals
|
|
36
|
+
|
|
37
|
+
- In scope: the own-row rule in `UserListComponent` (identity by `id` of the row vs. the current
|
|
38
|
+
user), tests, CHANGELOG entry, release.
|
|
39
|
+
- Explicit non-goals / do-not-touch:
|
|
40
|
+
- No backend change (dcm untouched).
|
|
41
|
+
- Rows other than the own row keep today's disabled-vs-enabled behaviour; no change to
|
|
42
|
+
`defaultCanEdit`.
|
|
43
|
+
- No other action on the own row changes (edit, extra row actions).
|
|
44
|
+
- Consumer pin bumps are not part of this WO.
|
|
45
|
+
|
|
46
|
+
## Tier · gates
|
|
47
|
+
|
|
48
|
+
- **Tier 3** — change inside shared-core, permission-adjacent UI.
|
|
49
|
+
- Precondition: none.
|
|
50
|
+
|
|
51
|
+
## Risks
|
|
52
|
+
|
|
53
|
+
- **Reaching the two affected apps needs a major bump:** kerzenziehen and reimbursements pin
|
|
54
|
+
`@micha.bigler/ui-core-micha` **2.41.3**; 3.0.0 removed chart layout props (`UCM-CHART-12`). The
|
|
55
|
+
fix only reaches them with that bump, which is its own work per app and is classified there by
|
|
56
|
+
the dependency-bump rule.
|
|
57
|
+
- The current user may be unknown while loading; with no current user the own-row rule must not
|
|
58
|
+
hide buttons on other rows or throw.
|
|
59
|
+
- `id` types: compare the row id and the current user id so that `3` and `"3"` match.
|
|
60
|
+
|
|
61
|
+
## Required tests to WRITE (you write them and run YOUR OWN new ones; the orchestrator's run is the gate)
|
|
62
|
+
|
|
63
|
+
In `tests/UserListComponent.test.jsx`:
|
|
64
|
+
|
|
65
|
+
1. Superuser, no `canDeleteUser`: own row has no delete button; another row has an enabled one.
|
|
66
|
+
2. Role `admin`, no `canDeleteUser`: same as 1.
|
|
67
|
+
3. `canDeleteUser` returning true for every row: own row still has no delete button.
|
|
68
|
+
4. Current user not yet loaded: other rows unaffected, no error.
|
|
69
|
+
5. **Mutation proof:** remove the own-row rule → tests 1 and 3 fail.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
# B. Implementation map — filled by the Orchestrator — ADDRESSED TO THE IMPLEMENTER
|
|
74
|
+
|
|
75
|
+
## Context package
|
|
76
|
+
|
|
77
|
+
- File: `src/components/UserListComponent.jsx` (current state, verified against `main` at `3.9.0`
|
|
78
|
+
post-`UCM-AUTH-10`, line numbers below are exact).
|
|
79
|
+
- **The fix must gate RENDERING, not just the disabled state.** `canDelete(row)` (line 151-156)
|
|
80
|
+
already decides enabled-vs-disabled; the delete `Button` (line 441-453) already renders
|
|
81
|
+
`disabled={!canDelete(row)}`. If the own-row rule were added INSIDE `canDelete`, the button would
|
|
82
|
+
still render, just permanently disabled — that is explicitly NOT what the Envelope asks for ("no
|
|
83
|
+
delete button — not a disabled one, none at all"). The rule must wrap the render, so a consumer's
|
|
84
|
+
`canDeleteUser` returning `true` for the own row cannot bring the button back (Required Test #3).
|
|
85
|
+
- Add a small helper next to `canDelete` (around line 156):
|
|
86
|
+
```js
|
|
87
|
+
const isOwnRow = (targetUser) => {
|
|
88
|
+
if (currentUser?.id == null || targetUser?.id == null) return false;
|
|
89
|
+
return String(targetUser.id) === String(currentUser.id);
|
|
90
|
+
};
|
|
91
|
+
```
|
|
92
|
+
- `currentUser?.id == null` (loose check, catches both `undefined` and `null`) covers the "current
|
|
93
|
+
user not yet loaded" risk: when there is no current user at all, every row's `isOwnRow` is
|
|
94
|
+
`false`, so nothing is hidden and nothing throws (Required Test #4) — this function must NOT be
|
|
95
|
+
the place that decides "hide everything while loading"; it only ever answers "is THIS row me",
|
|
96
|
+
and answers `false` when that question is unanswerable.
|
|
97
|
+
- `String(...) === String(...)` is the fix for the Risk note's `id` type mismatch (`3` vs `"3"`).
|
|
98
|
+
- Wire it into the existing render condition at line 438 — change
|
|
99
|
+
`{showDeleteAction && (` to `{showDeleteAction && !isOwnRow(row) && (` (the `Tooltip`/`Button`
|
|
100
|
+
JSX inside is otherwise byte-identical — do not touch its internals, only the gating condition
|
|
101
|
+
that decides whether that whole block renders at all).
|
|
102
|
+
- Do not touch `canDelete`/`canEdit`/`defaultCanEdit` (line 128-156) — the non-goal is explicit that
|
|
103
|
+
every OTHER row's enabled-vs-disabled behaviour stays exactly as today; this fix only removes the
|
|
104
|
+
own row's button, it does not change what `canDelete` returns for anyone (a consumer's
|
|
105
|
+
`canDeleteUser({ targetUser, currentUser, extraContext })` for the own row may still return
|
|
106
|
+
`true` internally — that return value is simply never consulted for the own row anymore, because
|
|
107
|
+
the render is gated before `canDelete(row)` is ever reached for that row's button).
|
|
108
|
+
|
|
109
|
+
## Test file — `tests/UserListComponent.test.jsx` (existing — read it first for the render harness,
|
|
110
|
+
## the `react-i18next`/`i18n` mock shape `UCM-AUTH-10` already fixed there, and `authApi` mocking
|
|
111
|
+
## convention already established; extend it, do not replace its structure)
|
|
112
|
+
|
|
113
|
+
Five cases, matching the Required Tests 1-5 exactly:
|
|
114
|
+
1. `currentUser = { id: 99, is_superuser: true, ... }`, `fetchUsersList` resolves a list including a
|
|
115
|
+
row with `id: 99` (the signed-in user) and at least one other row (e.g. `id: 1`); no
|
|
116
|
+
`canDeleteUser` prop passed. Assert: querying delete buttons by accessible name/role yields exactly
|
|
117
|
+
one (for the OTHER row), and it is not disabled; no delete button exists for the row whose `id`
|
|
118
|
+
is `99` (e.g. scope the query to that row's `TableRow` — check how existing tests in this file
|
|
119
|
+
locate a specific row, or use `within(row)` from `@testing-library/react` if not already
|
|
120
|
+
imported).
|
|
121
|
+
2. Same shape, `currentUser.role = 'admin'` instead of `is_superuser` — same assertions.
|
|
122
|
+
3. Pass `canDeleteUser={() => true}` (unconditionally true for every row, including the own one) —
|
|
123
|
+
assert the own row STILL has no delete button, while the other row's is enabled (proves the
|
|
124
|
+
render-level gate wins over a consumer prop, not just over the default `canEdit`-based logic).
|
|
125
|
+
4. Render with `currentUser = null` (or omit the prop) while `fetchUsersList` resolves a list
|
|
126
|
+
including a row that happens to share no id with anyone — assert the component does not throw
|
|
127
|
+
(no unhandled rejection/render error) and renders without crashing; per the Envelope's own
|
|
128
|
+
framing, unauthenticated/loading state does not by itself grant any row a delete button (this
|
|
129
|
+
test is about NOT crashing and NOT hiding unrelated rows, not about granting new permissions —
|
|
130
|
+
keep whatever the existing default-canEdit-based enabled/disabled state would be for other rows
|
|
131
|
+
in this configuration, do not assert a specific enabled/disabled state you have not verified from
|
|
132
|
+
`defaultCanEdit`'s own logic for a null `currentUser`, which already returns `false` unconditionally
|
|
133
|
+
at line 129 — so with the default `canDeleteUser`, every row is disabled-but-present in this
|
|
134
|
+
configuration; assert exactly that: buttons present and disabled, none of them thrown away).
|
|
135
|
+
5. **Mutation proof required by the Envelope itself:** after writing tests 1 and 3, temporarily
|
|
136
|
+
comment out or revert the `!isOwnRow(row)` addition in your own working copy, re-run tests 1 and
|
|
137
|
+
3 ONLY, confirm both fail, then restore the fix and confirm the full focused file is green again.
|
|
138
|
+
Narrate this in `PROGRESS` (which two tests failed, then the restore) rather than leaving a
|
|
139
|
+
permanently-broken toggle in the committed test file — same pattern already used successfully in
|
|
140
|
+
this repo's own `UCM-THEME-15`/`UCM-PRIM-2` work orders for an identical "prove it would fail"
|
|
141
|
+
requirement.
|
|
142
|
+
|
|
143
|
+
## Do-not-touch / invariants
|
|
144
|
+
|
|
145
|
+
- No backend change (dcm is a separate repo, untouched, and out of scope regardless).
|
|
146
|
+
- No change to `defaultCanEdit`/`canEdit`/`canDelete`'s return values for any OTHER row.
|
|
147
|
+
- No change to any other action on the own row (role select, extra row actions) — only the delete
|
|
148
|
+
button's presence.
|
|
149
|
+
- No consumer (`AccountPage`, etc.) touched — this fix is entirely inside `UserListComponent.jsx`.
|
|
150
|
+
|
|
151
|
+
## Target repo working directory (absolute)
|
|
152
|
+
|
|
153
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`)
|
|
154
|
+
|
|
155
|
+
## Preamble — a REQUIRED block IN this file, not something appended at invocation
|
|
156
|
+
|
|
157
|
+
> The text above is the COMPLETE spec — the committed WO file's content, not a plan to refine; there
|
|
158
|
+
> is no separate plan file. Read the nearest `AGENTS.md`, the relevant `.codex/skills/<role>/SKILL.md`, and the
|
|
159
|
+
> app `MEMORY.md` ONLY for conventions. Stay in scope; do not touch auth/permissions/deps/schema/CI
|
|
160
|
+
> unless the spec says so; do not update `MEMORY.md`. **Do NOT edit `WORK_ORDERS.md` — the register
|
|
161
|
+
> row and the review verdicts are the orchestrator's alone.** **Your tools are for editing source
|
|
162
|
+
> and test files and for running the tests you wrote — nothing else.** Do NOT install dependencies,
|
|
163
|
+
> touch a lockfile, run a package manager, or tidy up stray files; if something in the repo state
|
|
164
|
+
> blocks you, stop and report it as `RESULT: BLOCKED <reason>` instead of fixing it. Do NOT
|
|
165
|
+
> `git add`/`commit`/`push` — leave every
|
|
166
|
+
> change uncommitted in the working tree for the orchestrator's independent review. WRITE the tests
|
|
167
|
+
> the `Required tests` section calls for AND **RUN the tests you just wrote** to confirm they execute
|
|
168
|
+
> and pass — that is the ONLY test run you do (NOT the app's affected/full suite, NOT any review).
|
|
169
|
+
> The orchestrator re-runs the authoritative set + does the independent review after you finish —
|
|
170
|
+
> those are the gate; your own run does not count as the gate.
|
|
171
|
+
>
|
|
172
|
+
> Narrate continuously: a `PLAN: <step1> | <step2> | …` line up front, then a single-line
|
|
173
|
+
> `PROGRESS: [<n>/<total>] <present-tense action>` before every relevant action (and `… done` on
|
|
174
|
+
> completion), spaced so no gap exceeds ~2 min, stdout unbuffered, plus exactly one final
|
|
175
|
+
> `RESULT: DONE|BLOCKED <reason>`.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
# C. Orchestrator only — NOT ADDRESSED TO THE IMPLEMENTER
|
|
180
|
+
|
|
181
|
+
> **If you are the implementer reading this work order as your own specification: STOP at this line.
|
|
182
|
+
> Everything below describes what the Orchestrator does AFTER you finish. You do none of it — no
|
|
183
|
+
> reviewers, no verification run, no register edit, no commit.**
|
|
184
|
+
|
|
185
|
+
*(to be filled by the Orchestrator: execution directive; `reviewer` (all lenses) + `ui_reviewer` + `sec_reviewer`; release (patch: behaviour fix of an existing component); register row stays `released` until consumers bump; register the kerzenziehen and reimbursements bumps in their own registers; commit.)*
|