From 16e9673860d3e1e05f473007c412e0ad8ca6e2ef Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 16 Jul 2026 18:59:22 +0300 Subject: [PATCH 1/3] feat: registrant access-events panel (RDAP spec 13 Surface B) Add the "Who accessed my data" transparency panel to the domain page, showing which authorities have accessed a domain's registration data. - Redux accessEvents slice (keyed by domain uuid) + thunk fetchAccessEvents, wired into the store combiner and the three action-type constants. - api.fetchDomainAccessEvents(uuid) and the BFF getDomainAccessEvents handler, registered below the session-auth gate. The handler forwards only the registrant's session Bearer and attaches no client-supplied identity; ownership is re-derived independently by the registry. - DomainPage .page--block with a semantic Semantic-UI table (Institution / Category / Access time), uniquely-keyed rows, and an explicit empty state. Renders only the three fields the API returns (accessed_at, organization, category); no withheld field is displayed, logged or stored. - ET/EN i18n keys under domain.accessEvents.* (provisional copy, pending final sign-off). - Vitest: reducer slice, panel render + a11y + empty state, i18n key-set match, BFF handler (session Bearer forwarded, no body/token logging), and route-below-gate ordering. --- server/index.js | 1 + server/index.test.js | 19 +++ server/routes/apiRoute.js | 12 ++ server/routes/apiRoute.test.js | 72 ++++++++++++ src/pages/DomainPage/DomainPage.jsx | 67 +++++++++++ src/pages/DomainPage/DomainPage.test.jsx | 114 ++++++++++++++++++ src/redux/actions/index.js | 5 + src/redux/reducers/accessEvents.js | 79 +++++++++++++ src/redux/reducers/accessEvents.test.js | 129 +++++++++++++++++++++ src/redux/reducers/index.js | 2 + src/translations/accessEvents.i18n.test.js | 45 +++++++ src/translations/en.json | 6 + src/translations/et.json | 6 + src/utils/api.js | 4 +- 14 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 src/redux/reducers/accessEvents.js create mode 100644 src/redux/reducers/accessEvents.test.js create mode 100644 src/translations/accessEvents.i18n.test.js diff --git a/server/index.js b/server/index.js index bcca96d0..fc926cc6 100644 --- a/server/index.js +++ b/server/index.js @@ -184,6 +184,7 @@ app.all('/api/*', API.checkAuth); // Protected routes below app.get('/api/domains', API.getDomains); app.get('/api/domains/:uuid', API.getDomains); +app.get('/api/domains/:uuid/access_events', API.getDomainAccessEvents); app.post('/api/domains/:uuid/registry_lock', API.setDomainRegistryLock); app.delete('/api/domains/:uuid/registry_lock', API.deleteDomainRegistryLock); app.get('/api/contacts/:uuid/do_need_update_contacts', API.doNeedUpdateContacts); diff --git a/server/index.test.js b/server/index.test.js index 793d526a..f52d7713 100644 --- a/server/index.test.js +++ b/server/index.test.js @@ -79,4 +79,23 @@ describe('server/index smoke', () => { it('exports server instance', () => { expect(serverModule.default).toBeDefined(); }); + + it('registers the access_events route below the checkAuth session gate', async () => { + // `fs` is mocked in this file; read the real source via the un-mocked module. + const fs = await vi.importActual('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const dir = path.dirname(fileURLToPath(import.meta.url)); + const source = fs.readFileSync(path.join(dir, 'index.js'), 'utf8'); + + const gateIndex = source.indexOf("app.all('/api/*', API.checkAuth)"); + const routeIndex = source.indexOf( + "app.get('/api/domains/:uuid/access_events', API.getDomainAccessEvents)" + ); + + expect(gateIndex).toBeGreaterThan(-1); + expect(routeIndex).toBeGreaterThan(-1); + // The route must be declared AFTER the gate so it inherits the session-auth check. + expect(routeIndex).toBeGreaterThan(gateIndex); + }); }); diff --git a/server/routes/apiRoute.js b/server/routes/apiRoute.js index e2aae8a0..e566a95d 100644 --- a/server/routes/apiRoute.js +++ b/server/routes/apiRoute.js @@ -159,6 +159,18 @@ export default { ); }, + getDomainAccessEvents: async ({ params, session }, res) => { + // Forwards the registrant's existing SESSION Bearer (via the API(session) factory) to the + // registry's per-domain access-events endpoint. Attaches NO client-supplied registrant id — + // ownership is re-derived independently by the registry (defence in depth). handleResponse is + // reused unchanged; it does NOT log the success body, and no token is logged here (N2). + const { uuid } = params; + return handleResponse( + () => API(session).get(`/api/v1/registrant/domains/${uuid}/access_events`), + res + ); + }, + getMenu: async (req, res) => { const { type } = req.params; try { diff --git a/server/routes/apiRoute.test.js b/server/routes/apiRoute.test.js index 1aa06a42..539ef34b 100644 --- a/server/routes/apiRoute.test.js +++ b/server/routes/apiRoute.test.js @@ -298,6 +298,78 @@ describe('server/routes/apiRoute', () => { expect(r.__ctx.statusCode).toBe(401); }); + it('getDomainAccessEvents forwards the session Bearer to the per-domain registry endpoint', async () => { + const req = { + ...createSession(), + params: { uuid: 'test-uuid' }, + }; + const r = createRes(); + mockAxiosInstance.get.mockResolvedValue({ status: 200, data: [] }); + + await API.getDomainAccessEvents(req, r); + + // Calls exactly the per-domain registry endpoint, no query string / client identity appended. + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + '/api/v1/registrant/domains/test-uuid/access_events' + ); + + // The API(session) axios factory was created with the session Bearer in its Authorization header. + const createArgs = mockAxiosCreate.mock.calls.map((call) => call[0]); + const withBearer = createArgs.find( + (cfg) => cfg?.headers?.Authorization === 'Bearer test-token' + ); + expect(withBearer).toBeTruthy(); + + // No client-supplied registrant/contact identity is attached to the request. + const getArg = mockAxiosInstance.get.mock.calls[0][1]; + expect(getArg).toBeUndefined(); + }); + + it('getDomainAccessEvents returns the registry body verbatim (three-field events)', async () => { + const events = [ + { + accessed_at: '2026-07-10T12:00:00+03:00', + organization: 'Politsei- ja Piirivalveamet', + category: 'law_enforcement', + }, + ]; + const req = { + ...createSession(), + params: { uuid: 'test-uuid' }, + }; + const r = createRes(); + mockAxiosInstance.get.mockResolvedValue({ status: 200, data: events }); + + await API.getDomainAccessEvents(req, r); + + expect(r.__ctx.statusCode).toBe(200); + expect(r.__ctx.body).toEqual(events); + }); + + it('getDomainAccessEvents does not log the response body or the token', async () => { + const { logError, logWarn, logInfo } = await import('../utils/logger.js'); + const events = [ + { + accessed_at: '2026-07-10T12:00:00+03:00', + organization: 'Politsei- ja Piirivalveamet', + category: 'law_enforcement', + }, + ]; + const req = { + ...createSession(), + params: { uuid: 'test-uuid' }, + }; + const r = createRes(); + mockAxiosInstance.get.mockResolvedValue({ status: 200, data: events }); + + await API.getDomainAccessEvents(req, r); + + // Success path logs nothing at all (no body, no token). + expect(logError).not.toHaveBeenCalled(); + expect(logWarn).not.toHaveBeenCalled(); + expect(logInfo).not.toHaveBeenCalled(); + }); + it('handleResponse handles timeout error', async () => { const req = { ...createSession(), diff --git a/src/pages/DomainPage/DomainPage.jsx b/src/pages/DomainPage/DomainPage.jsx index 915b4490..3cedf9a5 100644 --- a/src/pages/DomainPage/DomainPage.jsx +++ b/src/pages/DomainPage/DomainPage.jsx @@ -33,13 +33,16 @@ import { } from '../../redux/reducers/domains'; import { fetchCompanies as fetchCompaniesAction } from '../../redux/reducers/companies'; import { updateContact as updateContactAction } from '../../redux/reducers/contacts'; +import { fetchAccessEvents as fetchAccessEventsAction } from '../../redux/reducers/accessEvents'; import Helpers from '../../utils/helpers'; const DomainPage = ({ + accessEvents, companies, contacts, domains, error, + fetchAccessEvents, fetchCompanies, fetchDomain, isLoading, @@ -52,6 +55,7 @@ const DomainPage = ({ const { id } = useParams(); const domain = domains[id]; const { uiElemSize } = ui; + const domainAccessEvents = accessEvents[id]; const [isDirty, setIsDirty] = useState(false); const [isLockable, setIsLockable] = useState(false); @@ -75,6 +79,14 @@ const DomainPage = ({ fetchData(); }, [domain, fetchDomain, isLoading, id, error, companies.isLoading, fetchCompanies]); + useEffect(() => { + // Once the domain is loaded, fetch the list of authorities that have accessed its data. + // Guarded so we fetch it once per domain uuid. + if (domain && domainAccessEvents === undefined) { + fetchAccessEvents(id); + } + }, [domain, domainAccessEvents, fetchAccessEvents, id]); + useEffect(() => { if (registrantContacts?.ident?.type === 'org') { if (companies.isLoading === null) { @@ -594,6 +606,59 @@ const DomainPage = ({ +
+ +
+

+ + }> + + +

+
+ {domainAccessEvents && domainAccessEvents.length ? ( + + + + + + + + + + + + + + + + {domainAccessEvents.map((event) => ( + + {event.organization || '-'} + {event.category} + {event.accessed_at} + + ))} + +
+ ) : ( + + )} +
+
{ }; const mapStateToProps = (state) => ({ + accessEvents: state.accessEvents.data, companies: state.companies, contacts: state.contacts.data, error: state.domains.error, @@ -724,6 +790,7 @@ const mapStateToProps = (state) => ({ const mapDispatchToProps = (dispatch) => bindActionCreators( { + fetchAccessEvents: fetchAccessEventsAction, fetchCompanies: fetchCompaniesAction, fetchDomain: fetchDomainAction, lockDomain: lockDomainAction, diff --git a/src/pages/DomainPage/DomainPage.test.jsx b/src/pages/DomainPage/DomainPage.test.jsx index ae5e8ca8..f17734dc 100644 --- a/src/pages/DomainPage/DomainPage.test.jsx +++ b/src/pages/DomainPage/DomainPage.test.jsx @@ -47,6 +47,11 @@ vi.mock('../../redux/reducers/domains', () => ({ }), })); +// Mock the access-events action (thunk); its dispatch is a no-op action in these tests +vi.mock('../../redux/reducers/accessEvents', () => ({ + fetchAccessEvents: () => ({ type: 'MOCK_FETCH_ACCESS_EVENTS' }), +})); + const createTestStore = (overrides = {}) => { const baseState = { ui: { @@ -92,6 +97,11 @@ const createTestStore = (overrides = {}) => { isLoading: null, message: null, }, + accessEvents: { + data: {}, + isLoading: false, + error: null, + }, }; // Create reducers that handle domain actions @@ -129,6 +139,7 @@ const createTestStore = (overrides = {}) => { }, contacts: (state = { ...baseState.contacts, ...overrides.contacts }) => state, companies: (state = { ...baseState.companies, ...overrides.companies }) => state, + accessEvents: (state = { ...baseState.accessEvents, ...overrides.accessEvents }) => state, }; return configureStore({ @@ -289,4 +300,107 @@ describe('DomainPage', () => { expect(container.textContent).toContain('Admin'); expect(container.textContent).toContain('test@admin.ee'); }); + + describe('access-events panel', () => { + const sampleEvents = [ + { + accessed_at: '2026-07-10T12:00:00+03:00', + organization: 'Politsei- ja Piirivalveamet', + category: 'law_enforcement', + }, + { + accessed_at: '2026-07-09T09:30:00+03:00', + organization: null, + category: 'court', + }, + ]; + + it('renders exactly the three returned fields and no withheld data', () => { + const eventsStore = createTestStore({ + accessEvents: { + data: { [mockDomain.id]: sampleEvents }, + isLoading: false, + error: null, + }, + }); + + const { container } = render( + + + + ); + + // panel title (et translation) + the three fields + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + expect(container.textContent).toContain('Politsei- ja Piirivalveamet'); + expect(container.textContent).toContain('law_enforcement'); + expect(container.textContent).toContain('court'); + expect(container.textContent).toContain('2026-07-10T12:00:00+03:00'); + + // withheld fields must never appear in the DOM + const withheld = [ + 'accessor_name', + 'grant_ref', + 'request_id', + 'caller_ip', + 'result_code', + ]; + withheld.forEach((field) => { + expect(container.textContent).not.toContain(field); + }); + }); + + it('uses a semantic header with descriptive columns and uniquely-keyed rows (a11y)', () => { + const eventsStore = createTestStore({ + accessEvents: { + data: { [mockDomain.id]: sampleEvents }, + isLoading: false, + error: null, + }, + }); + + const { container } = render( + + + + ); + + // Find the panel table by its column headers (et translations) + const headerCells = Array.from(container.querySelectorAll('thead th')).map((th) => + th.textContent.trim() + ); + expect(headerCells).toContain('Asutus'); + expect(headerCells).toContain('Kategooria'); + expect(headerCells).toContain('Vaatamise aeg'); + + // one row per event, each rendered (unique keys => both rows present) + const bodyRows = container.querySelectorAll('tbody tr'); + const accessRows = Array.from(bodyRows).filter( + (tr) => + tr.textContent.includes('law_enforcement') || tr.textContent.includes('court') + ); + expect(accessRows).toHaveLength(2); + }); + + it('renders the empty-state message (not an error/blank) when there are no events', () => { + const emptyStore = createTestStore({ + accessEvents: { + data: { [mockDomain.id]: [] }, + isLoading: false, + error: null, + }, + }); + + const { container } = render( + + + + ); + + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + expect(container.textContent).toContain( + 'Ükski asutus ei ole selle domeeni andmeid vaadanud.' + ); + }); + }); }); diff --git a/src/redux/actions/index.js b/src/redux/actions/index.js index b75dd999..b4925e10 100644 --- a/src/redux/actions/index.js +++ b/src/redux/actions/index.js @@ -30,6 +30,11 @@ export const RESPOND_REGISTRANT_CHANGE_REQUEST = 'RESPOND_REGISTRANT_CHANGE_REQU export const RESPOND_REGISTRANT_CHANGE_SUCCESS = 'RESPOND_REGISTRANT_CHANGE_SUCCESS'; export const RESPOND_REGISTRANT_CHANGE_FAILED = 'RESPOND_REGISTRANT_CHANGE_FAILED'; +// Access events (who accessed my domain data) +export const FETCH_ACCESS_EVENTS_REQUEST = 'FETCH_ACCESS_EVENTS_REQUEST'; +export const FETCH_ACCESS_EVENTS_SUCCESS = 'FETCH_ACCESS_EVENTS_SUCCESS'; +export const FETCH_ACCESS_EVENTS_FAILURE = 'FETCH_ACCESS_EVENTS_FAILURE'; + // Contacts export const FETCH_CONTACT_REQUEST = 'FETCH_CONTACT_REQUEST'; export const FETCH_CONTACT_SUCCESS = 'FETCH_CONTACT_SUCCESS'; diff --git a/src/redux/reducers/accessEvents.js b/src/redux/reducers/accessEvents.js new file mode 100644 index 00000000..5966e387 --- /dev/null +++ b/src/redux/reducers/accessEvents.js @@ -0,0 +1,79 @@ +/* eslint-disable */ +import api from '../../utils/api'; +import { + FETCH_ACCESS_EVENTS_REQUEST, + FETCH_ACCESS_EVENTS_SUCCESS, + FETCH_ACCESS_EVENTS_FAILURE, + LOGOUT_USER, +} from '../actions'; + +// Access events describe which authorities have accessed a domain's data. The registry returns, per +// event, EXACTLY three fields: { accessed_at, organization, category }. Nothing else is stored or +// surfaced here. + +const requestAccessEvents = (uuid) => ({ + payload: { uuid }, + type: FETCH_ACCESS_EVENTS_REQUEST, +}); + +const receiveAccessEvents = (uuid, events) => ({ + payload: { uuid, events }, + type: FETCH_ACCESS_EVENTS_SUCCESS, +}); + +const failAccessEvents = (uuid) => ({ + payload: { uuid }, + type: FETCH_ACCESS_EVENTS_FAILURE, +}); + +const fetchAccessEvents = (uuid) => (dispatch) => { + dispatch(requestAccessEvents(uuid)); + return api + .fetchDomainAccessEvents(uuid) + .then((res) => res.data) + .then((events) => dispatch(receiveAccessEvents(uuid, events))) + .catch(() => dispatch(failAccessEvents(uuid))); +}; + +const initialState = { + data: {}, + isLoading: false, + error: null, +}; + +export default function reducer(state = initialState, { payload, type }) { + switch (type) { + case LOGOUT_USER: + return initialState; + + case FETCH_ACCESS_EVENTS_REQUEST: + return { + ...state, + isLoading: true, + error: null, + }; + + case FETCH_ACCESS_EVENTS_SUCCESS: + return { + ...state, + data: { + ...state.data, + [payload.uuid]: payload.events, + }, + isLoading: false, + error: null, + }; + + case FETCH_ACCESS_EVENTS_FAILURE: + return { + ...state, + isLoading: false, + error: true, + }; + + default: + return state; + } +} + +export { initialState, fetchAccessEvents }; diff --git a/src/redux/reducers/accessEvents.test.js b/src/redux/reducers/accessEvents.test.js new file mode 100644 index 00000000..e43e685a --- /dev/null +++ b/src/redux/reducers/accessEvents.test.js @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import reducer, { initialState, fetchAccessEvents } from './accessEvents'; + +// Mock the api module instead of axios +vi.mock('../../utils/api', () => ({ + default: { + fetchDomainAccessEvents: vi.fn(), + }, +})); + +// Import api after mocking +import api from '../../utils/api'; + +const uuid = 'bd695cc9-1da8-4c39-b7ac-9a2055e0a93e'; +const sampleEvents = [ + { + accessed_at: '2026-07-10T12:00:00+03:00', + organization: 'Politsei- ja Piirivalveamet', + category: 'law_enforcement', + }, + { + accessed_at: '2026-07-09T09:30:00+03:00', + organization: null, + category: 'court', + }, +]; + +describe('Access events actions', () => { + let dispatch; + + beforeEach(() => { + vi.clearAllMocks(); + dispatch = vi.fn((action) => { + if (typeof action === 'function') { + return action(dispatch); + } + return action; + }); + }); + + describe('fetchAccessEvents', () => { + it('dispatches request then success with events keyed by uuid', async () => { + api.fetchDomainAccessEvents.mockResolvedValueOnce({ data: sampleEvents }); + + await fetchAccessEvents(uuid)(dispatch); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'FETCH_ACCESS_EVENTS_REQUEST', + payload: { uuid }, + }); + expect(dispatch).toHaveBeenLastCalledWith({ + type: 'FETCH_ACCESS_EVENTS_SUCCESS', + payload: { uuid, events: sampleEvents }, + }); + expect(api.fetchDomainAccessEvents).toHaveBeenCalledWith(uuid); + }); + + it('dispatches request then failure on error', async () => { + api.fetchDomainAccessEvents.mockRejectedValueOnce(new Error('Failed to fetch')); + + await fetchAccessEvents(uuid)(dispatch); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'FETCH_ACCESS_EVENTS_REQUEST', + payload: { uuid }, + }); + expect(dispatch).toHaveBeenLastCalledWith({ + type: 'FETCH_ACCESS_EVENTS_FAILURE', + payload: { uuid }, + }); + }); + }); +}); + +describe('Access events reducer', () => { + it('returns initial state', () => { + expect(reducer(undefined, {})).toEqual(initialState); + }); + + it('handles FETCH_ACCESS_EVENTS_REQUEST', () => { + const state = reducer(initialState, { + type: 'FETCH_ACCESS_EVENTS_REQUEST', + payload: { uuid }, + }); + expect(state.isLoading).toBe(true); + expect(state.error).toBe(null); + }); + + it('handles FETCH_ACCESS_EVENTS_SUCCESS keyed by uuid', () => { + const state = reducer(initialState, { + type: 'FETCH_ACCESS_EVENTS_SUCCESS', + payload: { uuid, events: sampleEvents }, + }); + expect(state.data[uuid]).toEqual(sampleEvents); + expect(state.isLoading).toBe(false); + expect(state.error).toBe(null); + }); + + it('preserves events for other uuids on a new success', () => { + const otherUuid = 'aaaaaaaa-1111-2222-3333-444444444444'; + const first = reducer(initialState, { + type: 'FETCH_ACCESS_EVENTS_SUCCESS', + payload: { uuid: otherUuid, events: [] }, + }); + const second = reducer(first, { + type: 'FETCH_ACCESS_EVENTS_SUCCESS', + payload: { uuid, events: sampleEvents }, + }); + expect(second.data[otherUuid]).toEqual([]); + expect(second.data[uuid]).toEqual(sampleEvents); + }); + + it('handles FETCH_ACCESS_EVENTS_FAILURE', () => { + const state = reducer(initialState, { + type: 'FETCH_ACCESS_EVENTS_FAILURE', + payload: { uuid }, + }); + expect(state.isLoading).toBe(false); + expect(state.error).toBe(true); + }); + + it('resets to initial state on LOGOUT_USER', () => { + const populated = reducer(initialState, { + type: 'FETCH_ACCESS_EVENTS_SUCCESS', + payload: { uuid, events: sampleEvents }, + }); + expect(reducer(populated, { type: 'LOGOUT_USER' })).toEqual(initialState); + }); +}); diff --git a/src/redux/reducers/index.js b/src/redux/reducers/index.js index 6aa4602c..4b2f70a5 100644 --- a/src/redux/reducers/index.js +++ b/src/redux/reducers/index.js @@ -7,9 +7,11 @@ import companies from './companies'; import contacts from './contacts'; import verification from './verification'; import filters from './filters'; +import accessEvents from './accessEvents'; export default withReduxStateSync( combineReducers({ + accessEvents, companies, contacts, domains, diff --git a/src/translations/accessEvents.i18n.test.js b/src/translations/accessEvents.i18n.test.js new file mode 100644 index 00000000..6bfea366 --- /dev/null +++ b/src/translations/accessEvents.i18n.test.js @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import en from './en.json'; +import et from './et.json'; + +// AC20: every new user-facing string is a React-Intl key under the domain.accessEvents.* namespace, +// present in BOTH en.json and et.json, with matching key sets and no empty values. +describe('domain.accessEvents.* i18n keys', () => { + const namespace = 'domain.accessEvents.'; + const expectedKeys = [ + 'domain.accessEvents.accessedAt', + 'domain.accessEvents.category', + 'domain.accessEvents.empty', + 'domain.accessEvents.institution', + 'domain.accessEvents.title', + 'domain.accessEvents.tooltip', + ].sort(); + + const enKeys = Object.keys(en) + .filter((k) => k.startsWith(namespace)) + .sort(); + const etKeys = Object.keys(et) + .filter((k) => k.startsWith(namespace)) + .sort(); + + it('en.json contains exactly the expected keys', () => { + expect(enKeys).toEqual(expectedKeys); + }); + + it('et.json contains exactly the expected keys', () => { + expect(etKeys).toEqual(expectedKeys); + }); + + it('the two locale key sets match', () => { + expect(enKeys).toEqual(etKeys); + }); + + it('every value is a non-empty string in both locales', () => { + expectedKeys.forEach((key) => { + expect(typeof en[key]).toBe('string'); + expect(en[key].trim().length).toBeGreaterThan(0); + expect(typeof et[key]).toBe('string'); + expect(et[key].trim().length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/translations/en.json b/src/translations/en.json index f26dc558..5752ad38 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -25,6 +25,12 @@ "domain.404.message.content": " ", "domain.404.message.title": "Unfortunately, the domain with this name was not found", "domain.404.title": "Domain not found", + "domain.accessEvents.accessedAt": "Access time", + "domain.accessEvents.category": "Category", + "domain.accessEvents.empty": "No authority has accessed this domain's data.", + "domain.accessEvents.institution": "Institution", + "domain.accessEvents.title": "Who accessed my data", + "domain.accessEvents.tooltip": "This list shows which authorities have accessed this domain's registration data. Only the institution, the category of access and the access time are shown. Recent accesses may appear after a short delay.", "domain.admin_contacts": "Admin contacts", "domain.changeContacts": "Change contacts", "domain.contact.admin": "Admin", diff --git a/src/translations/et.json b/src/translations/et.json index 00f9a047..9f28895a 100644 --- a/src/translations/et.json +++ b/src/translations/et.json @@ -25,6 +25,12 @@ "domain.404.message.content": " ", "domain.404.message.title": "Kahjuks sellise nimega domeeni ei leitud", "domain.404.title": "Domeeni ei leitud", + "domain.accessEvents.accessedAt": "Vaatamise aeg", + "domain.accessEvents.category": "Kategooria", + "domain.accessEvents.empty": "Ükski asutus ei ole selle domeeni andmeid vaadanud.", + "domain.accessEvents.institution": "Asutus", + "domain.accessEvents.title": "Kes on minu andmeid vaadanud", + "domain.accessEvents.tooltip": "See loend näitab, millised asutused on selle domeeni registreerimisandmeid vaadanud. Kuvatakse ainult asutus, vaatamise kategooria ja aeg. Hiljutised vaatamised võivad ilmuda väikese viivitusega.", "domain.admin_contacts": "Halduskontaktid", "domain.changeContacts": "Muuda kontakte", "domain.contact.admin": "Haldus", diff --git a/src/utils/api.js b/src/utils/api.js index 22e4f0b2..b4ecb7bb 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -87,9 +87,11 @@ export default { updateContact: (uuid, form) => instance.patch(`/api/contacts/${uuid}`, JSON.stringify(form)), - setDomainRegistryLock: (uuid, extensionsProhibited) => + setDomainRegistryLock: (uuid, extensionsProhibited) => instance.post(`/api/domains/${uuid}/registry_lock?extensionsProhibited=${extensionsProhibited}`), + fetchDomainAccessEvents: (uuid) => instance.get(`/api/domains/${uuid}/access_events`), + deleteDomainRegistryLock: (uuid) => instance.delete(`/api/domains/${uuid}/registry_lock`), }; From fc824e788d02fa33d6f7bb37edf6c2dbf4a15354 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 16 Jul 2026 19:21:33 +0300 Subject: [PATCH 2/3] fix: access-events panel must not read a failed/loading load as "no accesses" Two code-review fixes on the spec-13 Surface B transparency panel. W1 (DomainPage): the panel showed the empty-state message ("No authority has accessed this domain's data.") whenever the events list was falsy -- including while the fetch was in flight and permanently after a failure, a transparency false negative. Render four distinct states instead: loading (spinner) while in flight, error (message + retry button) on failure, the empty-state message ONLY on a successful empty array (Array.isArray && length === 0), and the table when populated. The withheld fields are still never rendered; only {accessed_at, organization, category} appear. W2 (accessEvents reducer): isLoading/error were single top-level flags, so one domain's in-flight/failed fetch drove another domain's panel. Scope per-request state by uuid -- state is now { byUuid: { [uuid]: { events, isLoading, error } } } -- so loading/error rendering is scoped to the domain being viewed. events stays undefined until success, letting the panel tell "loading/failed" apart from "loaded, empty". The fetch guard keys on the per-uuid record to fetch once and avoid a refetch loop. i18n: add domain.accessEvents.error and domain.accessEvents.retry to en.json and et.json (provisional copy, pending sign-off). Tests: reducer per-uuid scoping (a failure for A leaves B untouched); panel renders loading while in flight, error+retry on failure (not the empty message), and the empty message only on a successful empty array. --- src/pages/DomainPage/DomainPage.jsx | 53 ++++++++-- src/pages/DomainPage/DomainPage.test.jsx | 116 ++++++++++++++++++--- src/redux/reducers/accessEvents.js | 47 +++++---- src/redux/reducers/accessEvents.test.js | 55 +++++++--- src/translations/accessEvents.i18n.test.js | 2 + src/translations/en.json | 2 + src/translations/et.json | 2 + 7 files changed, 225 insertions(+), 52 deletions(-) diff --git a/src/pages/DomainPage/DomainPage.jsx b/src/pages/DomainPage/DomainPage.jsx index 3cedf9a5..5460971b 100644 --- a/src/pages/DomainPage/DomainPage.jsx +++ b/src/pages/DomainPage/DomainPage.jsx @@ -11,6 +11,7 @@ import { Label, Container, Table, + Loader, Modal, Checkbox, Confirm, @@ -55,7 +56,12 @@ const DomainPage = ({ const { id } = useParams(); const domain = domains[id]; const { uiElemSize } = ui; - const domainAccessEvents = accessEvents[id]; + // Per-domain access-events record: { events, isLoading, error }. `events` stays undefined until a + // fetch succeeds, so we can distinguish "loading / failed" from "loaded and genuinely empty". + const accessEventsRecord = accessEvents[id]; + const domainAccessEvents = accessEventsRecord?.events; + const accessEventsLoading = accessEventsRecord?.isLoading; + const accessEventsError = accessEventsRecord?.error; const [isDirty, setIsDirty] = useState(false); const [isLockable, setIsLockable] = useState(false); @@ -81,11 +87,13 @@ const DomainPage = ({ useEffect(() => { // Once the domain is loaded, fetch the list of authorities that have accessed its data. - // Guarded so we fetch it once per domain uuid. - if (domain && domainAccessEvents === undefined) { + // Guarded on the per-uuid record so we fetch once per domain: the record exists as soon as + // the request is dispatched (REQUEST/SUCCESS/FAILURE), which prevents a refetch loop while + // `events` is still undefined during loading or after a failure. + if (domain && accessEventsRecord === undefined) { fetchAccessEvents(id); } - }, [domain, domainAccessEvents, fetchAccessEvents, id]); + }, [domain, accessEventsRecord, fetchAccessEvents, id]); useEffect(() => { if (registrantContacts?.ident?.type === 'org') { @@ -616,7 +624,35 @@ const DomainPage = ({ - {domainAccessEvents && domainAccessEvents.length ? ( + {/* + * Four distinct states, so a failed or in-flight load never reads as + * "no authority accessed your data" (a transparency false negative): + * - loading: fetch in flight, no data yet -> spinner + * - error: fetch failed -> error/retry affordance (NOT the empty text) + * - empty: fetch succeeded with an empty array -> empty-state message + * - populated: the table + */} + {accessEventsLoading && !domainAccessEvents ? ( + + ) : accessEventsError ? ( +
+ + +
+ ) : domainAccessEvents && domainAccessEvents.length ? ( @@ -654,9 +690,10 @@ const DomainPage = ({ ))}
- ) : ( + ) : Array.isArray(domainAccessEvents) && + domainAccessEvents.length === 0 ? ( - )} + ) : null} @@ -777,7 +814,7 @@ const DomainContacts = ({ type, contacts }) => { }; const mapStateToProps = (state) => ({ - accessEvents: state.accessEvents.data, + accessEvents: state.accessEvents.byUuid, companies: state.companies, contacts: state.contacts.data, error: state.domains.error, diff --git a/src/pages/DomainPage/DomainPage.test.jsx b/src/pages/DomainPage/DomainPage.test.jsx index f17734dc..ec68be30 100644 --- a/src/pages/DomainPage/DomainPage.test.jsx +++ b/src/pages/DomainPage/DomainPage.test.jsx @@ -98,9 +98,7 @@ const createTestStore = (overrides = {}) => { message: null, }, accessEvents: { - data: {}, - isLoading: false, - error: null, + byUuid: {}, }, }; @@ -318,9 +316,13 @@ describe('DomainPage', () => { it('renders exactly the three returned fields and no withheld data', () => { const eventsStore = createTestStore({ accessEvents: { - data: { [mockDomain.id]: sampleEvents }, - isLoading: false, - error: null, + byUuid: { + [mockDomain.id]: { + events: sampleEvents, + isLoading: false, + error: false, + }, + }, }, }); @@ -353,9 +355,13 @@ describe('DomainPage', () => { it('uses a semantic header with descriptive columns and uniquely-keyed rows (a11y)', () => { const eventsStore = createTestStore({ accessEvents: { - data: { [mockDomain.id]: sampleEvents }, - isLoading: false, - error: null, + byUuid: { + [mockDomain.id]: { + events: sampleEvents, + isLoading: false, + error: false, + }, + }, }, }); @@ -382,12 +388,16 @@ describe('DomainPage', () => { expect(accessRows).toHaveLength(2); }); - it('renders the empty-state message (not an error/blank) when there are no events', () => { + it('renders the empty-state message ONLY on a successful empty array', () => { const emptyStore = createTestStore({ accessEvents: { - data: { [mockDomain.id]: [] }, - isLoading: false, - error: null, + byUuid: { + [mockDomain.id]: { + events: [], + isLoading: false, + error: false, + }, + }, }, }); @@ -402,5 +412,85 @@ describe('DomainPage', () => { 'Ükski asutus ei ole selle domeeni andmeid vaadanud.' ); }); + + it('shows a loading indicator (not the empty message) while the fetch is in flight', () => { + const loadingStore = createTestStore({ + accessEvents: { + byUuid: { + [mockDomain.id]: { + events: undefined, + isLoading: true, + error: false, + }, + }, + }, + }); + + const { container } = render( + + + + ); + + // panel is present, a loading indicator shows... + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + expect( + container.querySelector('[data-test="access-events-loading"]') + ).toBeInTheDocument(); + // ...and it must NOT falsely claim nobody accessed the data. + expect(container.textContent).not.toContain( + 'Ükski asutus ei ole selle domeeni andmeid vaadanud.' + ); + }); + + it('shows an error/retry affordance (not the empty message) on fetch failure', () => { + const errorStore = createTestStore({ + accessEvents: { + byUuid: { + [mockDomain.id]: { + events: undefined, + isLoading: false, + error: true, + }, + }, + }, + }); + + const { container } = render( + + + + ); + + // panel + error text + retry button... + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + expect( + container.querySelector('[data-test="access-events-error"]') + ).toBeInTheDocument(); + expect( + container.querySelector('[data-test="access-events-retry"]') + ).toBeInTheDocument(); + expect(container.textContent).toContain( + 'Vaatamiste ajalugu ei õnnestunud laadida. Palun proovi uuesti.' + ); + // ...and the empty message must NOT appear on a failed load. + expect(container.textContent).not.toContain( + 'Ükski asutus ei ole selle domeeni andmeid vaadanud.' + ); + }); + + it('does not show the empty message before any fetch record exists (undefined)', () => { + // No byUuid record for this domain at all -> neither empty nor error/loading text. + const { container } = render( + + + + ); + + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + expect(container.textContent).not.toContain( + 'Ükski asutus ei ole selle domeeni andmeid vaadanud.' + ); + }); }); }); diff --git a/src/redux/reducers/accessEvents.js b/src/redux/reducers/accessEvents.js index 5966e387..a0ad6c2f 100644 --- a/src/redux/reducers/accessEvents.js +++ b/src/redux/reducers/accessEvents.js @@ -10,6 +10,10 @@ import { // Access events describe which authorities have accessed a domain's data. The registry returns, per // event, EXACTLY three fields: { accessed_at, organization, category }. Nothing else is stored or // surfaced here. +// +// Per-request state (events, isLoading, error) is keyed by domain uuid so that an in-flight or +// failed fetch for one domain can never drive another domain's panel. This matters for a +// transparency feature: a failed load for domain A must not read as "no accesses" on domain B. const requestAccessEvents = (uuid) => ({ payload: { uuid }, @@ -35,41 +39,48 @@ const fetchAccessEvents = (uuid) => (dispatch) => { .catch(() => dispatch(failAccessEvents(uuid))); }; +// byUuid holds one { events, isLoading, error } record per domain uuid. events stays undefined +// until a successful fetch, so the panel can tell "loading / failed" apart from "loaded, empty". const initialState = { - data: {}, - isLoading: false, - error: null, + byUuid: {}, }; +const recordFor = (state, uuid) => + state.byUuid[uuid] || { events: undefined, isLoading: false, error: false }; + +const withRecord = (state, uuid, record) => ({ + ...state, + byUuid: { + ...state.byUuid, + [uuid]: record, + }, +}); + export default function reducer(state = initialState, { payload, type }) { switch (type) { case LOGOUT_USER: return initialState; case FETCH_ACCESS_EVENTS_REQUEST: - return { - ...state, + return withRecord(state, payload.uuid, { + ...recordFor(state, payload.uuid), isLoading: true, - error: null, - }; + error: false, + }); case FETCH_ACCESS_EVENTS_SUCCESS: - return { - ...state, - data: { - ...state.data, - [payload.uuid]: payload.events, - }, + return withRecord(state, payload.uuid, { + events: payload.events, isLoading: false, - error: null, - }; + error: false, + }); case FETCH_ACCESS_EVENTS_FAILURE: - return { - ...state, + return withRecord(state, payload.uuid, { + ...recordFor(state, payload.uuid), isLoading: false, error: true, - }; + }); default: return state; diff --git a/src/redux/reducers/accessEvents.test.js b/src/redux/reducers/accessEvents.test.js index e43e685a..8364d22a 100644 --- a/src/redux/reducers/accessEvents.test.js +++ b/src/redux/reducers/accessEvents.test.js @@ -73,17 +73,21 @@ describe('Access events actions', () => { }); describe('Access events reducer', () => { + const otherUuid = 'aaaaaaaa-1111-2222-3333-444444444444'; + it('returns initial state', () => { expect(reducer(undefined, {})).toEqual(initialState); }); - it('handles FETCH_ACCESS_EVENTS_REQUEST', () => { + it('handles FETCH_ACCESS_EVENTS_REQUEST scoped to the uuid', () => { const state = reducer(initialState, { type: 'FETCH_ACCESS_EVENTS_REQUEST', payload: { uuid }, }); - expect(state.isLoading).toBe(true); - expect(state.error).toBe(null); + expect(state.byUuid[uuid].isLoading).toBe(true); + expect(state.byUuid[uuid].error).toBe(false); + // events stays undefined while loading -> the panel must not read this as "empty" + expect(state.byUuid[uuid].events).toBeUndefined(); }); it('handles FETCH_ACCESS_EVENTS_SUCCESS keyed by uuid', () => { @@ -91,13 +95,12 @@ describe('Access events reducer', () => { type: 'FETCH_ACCESS_EVENTS_SUCCESS', payload: { uuid, events: sampleEvents }, }); - expect(state.data[uuid]).toEqual(sampleEvents); - expect(state.isLoading).toBe(false); - expect(state.error).toBe(null); + expect(state.byUuid[uuid].events).toEqual(sampleEvents); + expect(state.byUuid[uuid].isLoading).toBe(false); + expect(state.byUuid[uuid].error).toBe(false); }); - it('preserves events for other uuids on a new success', () => { - const otherUuid = 'aaaaaaaa-1111-2222-3333-444444444444'; + it('preserves records for other uuids on a new success', () => { const first = reducer(initialState, { type: 'FETCH_ACCESS_EVENTS_SUCCESS', payload: { uuid: otherUuid, events: [] }, @@ -106,17 +109,43 @@ describe('Access events reducer', () => { type: 'FETCH_ACCESS_EVENTS_SUCCESS', payload: { uuid, events: sampleEvents }, }); - expect(second.data[otherUuid]).toEqual([]); - expect(second.data[uuid]).toEqual(sampleEvents); + expect(second.byUuid[otherUuid].events).toEqual([]); + expect(second.byUuid[uuid].events).toEqual(sampleEvents); }); - it('handles FETCH_ACCESS_EVENTS_FAILURE', () => { + it('handles FETCH_ACCESS_EVENTS_FAILURE scoped to the uuid', () => { const state = reducer(initialState, { type: 'FETCH_ACCESS_EVENTS_FAILURE', payload: { uuid }, }); - expect(state.isLoading).toBe(false); - expect(state.error).toBe(true); + expect(state.byUuid[uuid].isLoading).toBe(false); + expect(state.byUuid[uuid].error).toBe(true); + // events stays undefined on failure -> the panel must not read this as "empty" + expect(state.byUuid[uuid].events).toBeUndefined(); + }); + + it('scopes loading/error per uuid: a failure for A does not touch B (W2)', () => { + // A loads successfully (empty result), then B fails. + const afterA = reducer(initialState, { + type: 'FETCH_ACCESS_EVENTS_SUCCESS', + payload: { uuid: otherUuid, events: [] }, + }); + const afterBRequest = reducer(afterA, { + type: 'FETCH_ACCESS_EVENTS_REQUEST', + payload: { uuid }, + }); + const afterBFailure = reducer(afterBRequest, { + type: 'FETCH_ACCESS_EVENTS_FAILURE', + payload: { uuid }, + }); + + // B is errored... + expect(afterBFailure.byUuid[uuid].error).toBe(true); + expect(afterBFailure.byUuid[uuid].isLoading).toBe(false); + // ...but A is untouched: still a successful empty load, no error, not loading. + expect(afterBFailure.byUuid[otherUuid].events).toEqual([]); + expect(afterBFailure.byUuid[otherUuid].error).toBe(false); + expect(afterBFailure.byUuid[otherUuid].isLoading).toBe(false); }); it('resets to initial state on LOGOUT_USER', () => { diff --git a/src/translations/accessEvents.i18n.test.js b/src/translations/accessEvents.i18n.test.js index 6bfea366..2ca24956 100644 --- a/src/translations/accessEvents.i18n.test.js +++ b/src/translations/accessEvents.i18n.test.js @@ -10,7 +10,9 @@ describe('domain.accessEvents.* i18n keys', () => { 'domain.accessEvents.accessedAt', 'domain.accessEvents.category', 'domain.accessEvents.empty', + 'domain.accessEvents.error', 'domain.accessEvents.institution', + 'domain.accessEvents.retry', 'domain.accessEvents.title', 'domain.accessEvents.tooltip', ].sort(); diff --git a/src/translations/en.json b/src/translations/en.json index 5752ad38..78d4c559 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -28,7 +28,9 @@ "domain.accessEvents.accessedAt": "Access time", "domain.accessEvents.category": "Category", "domain.accessEvents.empty": "No authority has accessed this domain's data.", + "domain.accessEvents.error": "The access history could not be loaded. Please try again.", "domain.accessEvents.institution": "Institution", + "domain.accessEvents.retry": "Try again", "domain.accessEvents.title": "Who accessed my data", "domain.accessEvents.tooltip": "This list shows which authorities have accessed this domain's registration data. Only the institution, the category of access and the access time are shown. Recent accesses may appear after a short delay.", "domain.admin_contacts": "Admin contacts", diff --git a/src/translations/et.json b/src/translations/et.json index 9f28895a..b8e0ec8d 100644 --- a/src/translations/et.json +++ b/src/translations/et.json @@ -28,7 +28,9 @@ "domain.accessEvents.accessedAt": "Vaatamise aeg", "domain.accessEvents.category": "Kategooria", "domain.accessEvents.empty": "Ükski asutus ei ole selle domeeni andmeid vaadanud.", + "domain.accessEvents.error": "Vaatamiste ajalugu ei õnnestunud laadida. Palun proovi uuesti.", "domain.accessEvents.institution": "Asutus", + "domain.accessEvents.retry": "Proovi uuesti", "domain.accessEvents.title": "Kes on minu andmeid vaadanud", "domain.accessEvents.tooltip": "See loend näitab, millised asutused on selle domeeni registreerimisandmeid vaadanud. Kuvatakse ainult asutus, vaatamise kategooria ja aeg. Hiljutised vaatamised võivad ilmuda väikese viivitusega.", "domain.admin_contacts": "Halduskontaktid", From b41c665ea6fa0d9a27a2c62252e33f57faddb5bd Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Mon, 3 Aug 2026 10:12:02 +0300 Subject: [PATCH 3/3] feat: build out the access-events panel UI to the portal design system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel shipped as a working stub: raw ISO timestamps, the raw category enum, a bare "-" for a missing institution, and 100 lines of JSX inline in DomainPage. Move it into src/components/DomainAccessEvents/ alongside the other feature components, and render it the way the rest of the portal renders things: DD.MM.Y HH:mm dates (moment, as in DomainList/UserData), translated category labels with a circular colour dot mirroring DomainStatuses, a translated fallback for an unrecorded institution, a semantic Message for the error state, and the empty state as a header paragraph like the nameserver and DNSSEC blocks. The four states stay distinct: an in-flight or failed load still never renders as "no authority accessed your data". Show the panel only to the domain's direct registrant. The registry scopes events to Contact.registrant_user_direct_contacts, but grants domain access to tech/admin contacts and company representatives too, so those viewers get 200 with an empty array — which the panel would have stated as "no authority has accessed this domain's data". We cannot know that for them, so neither the panel nor the request happens on their behalf. Widen this together with own_ids when the registry widens it. An unrecognised category renders its raw value rather than disappearing, so a registry-side addition surfaces as data. --- .../DomainAccessEvents/DomainAccessEvents.jsx | 150 ++++++++++++++++++ .../DomainAccessEvents.test.jsx | 144 +++++++++++++++++ .../_DomainAccessEvents.scss | 9 ++ src/components/index.js | 1 + src/index.scss | 1 + src/pages/DomainPage/DomainPage.jsx | 111 +++---------- src/pages/DomainPage/DomainPage.test.jsx | 58 ++++--- src/redux/reducers/accessEvents.js | 1 - src/translations/accessEvents.i18n.test.js | 7 + src/translations/en.json | 5 + src/translations/et.json | 5 + 11 files changed, 373 insertions(+), 119 deletions(-) create mode 100644 src/components/DomainAccessEvents/DomainAccessEvents.jsx create mode 100644 src/components/DomainAccessEvents/DomainAccessEvents.test.jsx create mode 100644 src/components/DomainAccessEvents/_DomainAccessEvents.scss diff --git a/src/components/DomainAccessEvents/DomainAccessEvents.jsx b/src/components/DomainAccessEvents/DomainAccessEvents.jsx new file mode 100644 index 00000000..f70ed50f --- /dev/null +++ b/src/components/DomainAccessEvents/DomainAccessEvents.jsx @@ -0,0 +1,150 @@ +import { FormattedMessage } from 'react-intl'; +import { Button, Container, Icon, Label, Loader, Message, Popup, Table } from 'semantic-ui-react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; + +// The privilege categories the registry can return (RdapPrivilegeGrant::CATEGORIES). The colour is +// a scanning aid only, mirroring how domain statuses are dotted elsewhere on this page. A category +// the portal does not know yet still renders — raw value, neutral dot — so a registry-side addition +// shows up as data rather than disappearing. +const CATEGORY_COLORS = { + cert: 'teal', + eis_internal: 'grey', + police: 'blue', + ria: 'violet', +}; + +const isKnownCategory = (category) => + Object.prototype.hasOwnProperty.call(CATEGORY_COLORS, category); + +// The endpoint sends ISO-8601; every other date in the portal reads DD.MM.Y HH:mm. An unparseable +// value falls back to the raw string rather than rendering "Invalid date". +const formatAccessedAt = (value) => { + const parsed = moment(value); + return parsed.isValid() ? parsed.format('DD.MM.Y HH:mm') : value; +}; + +/** + * "Who accessed my data" — the authority accesses the registry discloses to this domain's + * registrant (RDAP spec 13, Surface B). + * + * Presentational only: the caller owns fetching and decides whether the panel is shown at all. + * `events` stays undefined until a fetch succeeds, which is what keeps the four states apart — + * an in-flight or failed load must never render as "no authority accessed your data". + */ +const DomainAccessEvents = ({ error = false, events, isLoading = false, onRetry, uiElemSize }) => { + const hasEvents = Array.isArray(events) && events.length > 0; + const isEmpty = !error && Array.isArray(events) && events.length === 0; + + return ( +
+ +
+

+ + }> + + +

+ {isEmpty ? : null} +
+ {isLoading && !hasEvents ? ( +
+ +
+ ) : null} + {error ? ( + + + + + + + ) : null} + {!error && hasEvents ? ( + + + + + + + + + + + + + + + + {events.map((event, index) => ( + // The registry logs every request separately (no dedup), so two + // events can be identical in all three fields — the position in the + // server-ordered list is what makes the key unique. + + + {event.organization || ( + + )} + + + + {formatAccessedAt(event.accessed_at)} + + ))} + +
+ ) : null} +
+
+ ); +}; + +DomainAccessEvents.propTypes = { + error: PropTypes.bool, + events: PropTypes.arrayOf( + PropTypes.shape({ + accessed_at: PropTypes.string, + category: PropTypes.string, + organization: PropTypes.string, + }) + ), + isLoading: PropTypes.bool, + onRetry: PropTypes.func.isRequired, + uiElemSize: PropTypes.string, +}; + +export default DomainAccessEvents; diff --git a/src/components/DomainAccessEvents/DomainAccessEvents.test.jsx b/src/components/DomainAccessEvents/DomainAccessEvents.test.jsx new file mode 100644 index 00000000..2203ad17 --- /dev/null +++ b/src/components/DomainAccessEvents/DomainAccessEvents.test.jsx @@ -0,0 +1,144 @@ +import { render, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { configureStore } from '@reduxjs/toolkit'; +import moment from 'moment'; +import DomainAccessEvents from './DomainAccessEvents'; +import Providers from '../../__mocks__/Providers'; + +const FORMAT = 'DD.MM.Y HH:mm'; + +const createTestStore = () => + configureStore({ + reducer: { + ui: ( + state = { uiElemSize: 'small', lang: 'et', menus: { main: [] }, isMainMenuOpen: false } + ) => state, + }, + }); + +const EMPTY_MESSAGE = 'Ükski asutus ei ole selle domeeni andmeid vaadanud.'; +const ERROR_MESSAGE = 'Vaatamiste ajalugu ei õnnestunud laadida. Palun proovi uuesti.'; + +const events = [ + { + accessed_at: '2026-07-10T12:00:00+03:00', + organization: 'Politsei- ja Piirivalveamet', + category: 'police', + }, + { + accessed_at: '2026-07-09T09:30:00+03:00', + organization: null, + category: 'cert', + }, +]; + +describe('DomainAccessEvents', () => { + let store; + let onRetry; + + const renderPanel = (props = {}) => + render( + + + + ); + + beforeEach(() => { + store = createTestStore(); + onRetry = vi.fn(); + }); + + it('renders translated categories, an institution fallback and portal-formatted timestamps', () => { + const { container } = renderPanel({ events }); + + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + + const rows = Array.from(container.querySelectorAll('[data-test="access-events-row"]')).map( + (row) => Array.from(row.querySelectorAll('td')).map((td) => td.textContent.trim()) + ); + + // The timestamp is rendered in the viewer's own timezone (moment's default), so the + // expectation is derived the same way rather than hardcoded — what is asserted is the + // DD.MM.Y HH:mm shape the rest of the portal uses, not a fixed offset. + expect(rows).toEqual([ + ['Politsei- ja Piirivalveamet', 'Politsei', moment(events[0].accessed_at).format(FORMAT)], + ['Määramata', 'CERT', moment(events[1].accessed_at).format(FORMAT)], + ]); + rows.forEach(([, , accessedAt]) => { + expect(accessedAt).toMatch(/^\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}$/); + }); + + // the raw category enum must never reach the user + expect(container.textContent).not.toContain('police'); + expect(container.textContent).not.toContain(events[0].accessed_at); + }); + + it('renders an unrecognised category as its raw value instead of dropping the row', () => { + const { container } = renderPanel({ + events: [{ ...events[0], category: 'some_new_category' }], + }); + + expect(container.querySelectorAll('[data-test="access-events-row"]')).toHaveLength(1); + expect(container.textContent).toContain('some_new_category'); + }); + + it('renders only the three disclosed fields', () => { + const { container } = renderPanel({ events }); + + ['accessor_name', 'grant_ref', 'request_id', 'caller_ip', 'result_code'].forEach((field) => { + expect(container.textContent).not.toContain(field); + }); + }); + + it('uses a semantic header with descriptive columns (a11y)', () => { + const { container } = renderPanel({ events }); + + const headerCells = Array.from(container.querySelectorAll('thead th')).map((th) => + th.textContent.trim() + ); + expect(headerCells).toEqual(['Asutus', 'Kategooria', 'Vaatamise aeg']); + }); + + it('shows the empty state only on a successful empty response', () => { + const { container } = renderPanel({ events: [] }); + + expect(container.textContent).toContain(EMPTY_MESSAGE); + expect(container.querySelector('table')).not.toBeInTheDocument(); + }); + + it('shows a loader — never the empty state — while the fetch is in flight', () => { + const { container } = renderPanel({ isLoading: true }); + + expect(container.querySelector('[data-test="access-events-loading"]')).toBeInTheDocument(); + expect(container.textContent).not.toContain(EMPTY_MESSAGE); + }); + + it('shows an error with a working retry — never the empty state — on failure', () => { + const { container } = renderPanel({ error: true }); + + expect(container.querySelector('[data-test="access-events-error"]')).toBeInTheDocument(); + expect(container.textContent).toContain(ERROR_MESSAGE); + expect(container.textContent).not.toContain(EMPTY_MESSAGE); + + fireEvent.click(container.querySelector('[data-test="access-events-retry"]')); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('keeps the empty state away when a refresh fails over a previously empty result', () => { + // The reducer keeps the last successful payload on FAILURE, so an empty array can coexist + // with error: true. That combination must read as "could not load", not as "nobody looked". + const { container } = renderPanel({ error: true, events: [] }); + + expect(container.textContent).toContain(ERROR_MESSAGE); + expect(container.textContent).not.toContain(EMPTY_MESSAGE); + }); + + it('renders nothing but the header before any fetch has happened', () => { + const { container } = renderPanel(); + + expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); + expect(container.textContent).not.toContain(EMPTY_MESSAGE); + expect(container.textContent).not.toContain(ERROR_MESSAGE); + expect(container.querySelector('table')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/DomainAccessEvents/_DomainAccessEvents.scss b/src/components/DomainAccessEvents/_DomainAccessEvents.scss new file mode 100644 index 00000000..8ea1b914 --- /dev/null +++ b/src/components/DomainAccessEvents/_DomainAccessEvents.scss @@ -0,0 +1,9 @@ +@use "../../global-scss/all" as *; + +.domain-access-events { + &--loading { + // Semantic's inline loader has no intrinsic height; without this the block collapses to the + // header while the fetch is in flight and the page visibly jumps once the table arrives. + padding: 40px 0; + } +} diff --git a/src/components/index.js b/src/components/index.js index f04a4ddf..8a0b7c83 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -2,6 +2,7 @@ export { default as ScrollToTop } from './ScrollToTop/ScrollToTop'; export { default as MainLayout } from './common/MainLayout/MainLayout'; export { default as Loading } from './common/Loading/Loading'; export { default as DomainList } from './DomainList/DomainList'; +export { default as DomainAccessEvents } from './DomainAccessEvents/DomainAccessEvents'; export { default as UserData } from './UserData/UserData'; export { default as WhoIsEdit } from './WhoIsEdit/WhoIsEdit'; export { default as MessageModule } from './modules/MessageModule/MessageModule'; diff --git a/src/index.scss b/src/index.scss index 0bec2677..af101bc1 100644 --- a/src/index.scss +++ b/src/index.scss @@ -15,6 +15,7 @@ @use "./components/common/MainFooter/MainFooter"; @use "./components/common/MainLayout/MainLayout"; @use "./components/DomainList/DomainList"; +@use "./components/DomainAccessEvents/DomainAccessEvents"; @use "./components/UserData/UserData"; // Pages diff --git a/src/pages/DomainPage/DomainPage.jsx b/src/pages/DomainPage/DomainPage.jsx index 5460971b..a8e9a78a 100644 --- a/src/pages/DomainPage/DomainPage.jsx +++ b/src/pages/DomainPage/DomainPage.jsx @@ -11,7 +11,6 @@ import { Label, Container, Table, - Loader, Modal, Checkbox, Confirm, @@ -25,6 +24,7 @@ import { PageMessage, MainLayout, WhoIsConfirmDialog, + DomainAccessEvents, } from '../../components'; import domainStatuses from '../../utils/domainStatuses.json'; import { @@ -59,9 +59,17 @@ const DomainPage = ({ // Per-domain access-events record: { events, isLoading, error }. `events` stays undefined until a // fetch succeeds, so we can distinguish "loading / failed" from "loaded and genuinely empty". const accessEventsRecord = accessEvents[id]; - const domainAccessEvents = accessEventsRecord?.events; - const accessEventsLoading = accessEventsRecord?.isLoading; - const accessEventsError = accessEventsRecord?.error; + // The registry scopes access events to the caller's DIRECT registrant contact — the private + // person whose ident matches (Contact.registrant_user_direct_contacts). A tech/admin contact or + // a company representative passes the domain check but gets an empty list, which the panel + // would render as "no authority has accessed this domain's data" — a false negative in a + // feature whose whole point is transparency. So the panel is shown, and fetched, only for the + // direct registrant. When the registry widens own_ids to registrant_user_contacts (company + // representatives; spec 13 grounding §6 open question), widen this check with it. + const registrantContact = contacts[domain?.registrant?.id]; + const isDomainRegistrant = Boolean( + registrantContact?.ident?.type === 'priv' && registrantContact.ident.code === user.ident + ); const [isDirty, setIsDirty] = useState(false); const [isLockable, setIsLockable] = useState(false); @@ -90,10 +98,10 @@ const DomainPage = ({ // Guarded on the per-uuid record so we fetch once per domain: the record exists as soon as // the request is dispatched (REQUEST/SUCCESS/FAILURE), which prevents a refetch loop while // `events` is still undefined during loading or after a failure. - if (domain && accessEventsRecord === undefined) { + if (domain && isDomainRegistrant && accessEventsRecord === undefined) { fetchAccessEvents(id); } - }, [domain, accessEventsRecord, fetchAccessEvents, id]); + }, [domain, isDomainRegistrant, accessEventsRecord, fetchAccessEvents, id]); useEffect(() => { if (registrantContacts?.ident?.type === 'org') { @@ -614,88 +622,15 @@ const DomainPage = ({ -
- -
-

- - }> - - -

-
- {/* - * Four distinct states, so a failed or in-flight load never reads as - * "no authority accessed your data" (a transparency false negative): - * - loading: fetch in flight, no data yet -> spinner - * - error: fetch failed -> error/retry affordance (NOT the empty text) - * - empty: fetch succeeded with an empty array -> empty-state message - * - populated: the table - */} - {accessEventsLoading && !domainAccessEvents ? ( - - ) : accessEventsError ? ( -
- - -
- ) : domainAccessEvents && domainAccessEvents.length ? ( - - - - - - - - - - - - - - - - {domainAccessEvents.map((event) => ( - - {event.organization || '-'} - {event.category} - {event.accessed_at} - - ))} - -
- ) : Array.isArray(domainAccessEvents) && - domainAccessEvents.length === 0 ? ( - - ) : null} -
-
+ {isDomainRegistrant ? ( + fetchAccessEvents(id)} + uiElemSize={uiElemSize} + /> + ) : null} ({ })); // Mock the access-events action (thunk); its dispatch is a no-op action in these tests +const { mockFetchAccessEvents } = vi.hoisted(() => ({ + mockFetchAccessEvents: vi.fn(() => ({ type: 'MOCK_FETCH_ACCESS_EVENTS' })), +})); + vi.mock('../../redux/reducers/accessEvents', () => ({ - fetchAccessEvents: () => ({ type: 'MOCK_FETCH_ACCESS_EVENTS' }), + fetchAccessEvents: mockFetchAccessEvents, })); const createTestStore = (overrides = {}) => { @@ -304,12 +308,12 @@ describe('DomainPage', () => { { accessed_at: '2026-07-10T12:00:00+03:00', organization: 'Politsei- ja Piirivalveamet', - category: 'law_enforcement', + category: 'police', }, { accessed_at: '2026-07-09T09:30:00+03:00', organization: null, - category: 'court', + category: 'cert', }, ]; @@ -332,12 +336,10 @@ describe('DomainPage', () => { ); - // panel title (et translation) + the three fields + // panel title (et translation) + the three fields, one row per event expect(container.textContent).toContain('Kes on minu andmeid vaadanud'); expect(container.textContent).toContain('Politsei- ja Piirivalveamet'); - expect(container.textContent).toContain('law_enforcement'); - expect(container.textContent).toContain('court'); - expect(container.textContent).toContain('2026-07-10T12:00:00+03:00'); + expect(container.querySelectorAll('[data-test="access-events-row"]')).toHaveLength(2); // withheld fields must never appear in the DOM const withheld = [ @@ -352,40 +354,36 @@ describe('DomainPage', () => { }); }); - it('uses a semantic header with descriptive columns and uniquely-keyed rows (a11y)', () => { - const eventsStore = createTestStore({ - accessEvents: { - byUuid: { - [mockDomain.id]: { - events: sampleEvents, - isLoading: false, - error: false, + it('is hidden — and never fetched — for a viewer who is not the direct registrant', () => { + // The registry scopes events to the caller's own ident-matched registrant contact, so a + // tech/admin contact or a company representative would get an empty list. Rendering the + // panel for them would state "no authority has accessed this domain's data", which we + // cannot actually know. It must not appear at all. + const otherPersonStore = createTestStore({ + contacts: { + data: { + ...Object.fromEntries(contacts.map((contact) => [contact.id, contact])), + [mockDomain.registrant.id]: { + ...contacts.find((c) => c.id === mockDomain.registrant.id), + ident: { code: '00000000000', type: 'priv', country_code: 'EE' }, }, }, + message: null, }, }); const { container } = render( - + ); - // Find the panel table by its column headers (et translations) - const headerCells = Array.from(container.querySelectorAll('thead th')).map((th) => - th.textContent.trim() - ); - expect(headerCells).toContain('Asutus'); - expect(headerCells).toContain('Kategooria'); - expect(headerCells).toContain('Vaatamise aeg'); - - // one row per event, each rendered (unique keys => both rows present) - const bodyRows = container.querySelectorAll('tbody tr'); - const accessRows = Array.from(bodyRows).filter( - (tr) => - tr.textContent.includes('law_enforcement') || tr.textContent.includes('court') + expect(container.textContent).not.toContain('Kes on minu andmeid vaadanud'); + expect(container.textContent).not.toContain( + 'Ükski asutus ei ole selle domeeni andmeid vaadanud.' ); - expect(accessRows).toHaveLength(2); + // no panel means no request either — the endpoint is never called on their behalf + expect(mockFetchAccessEvents).not.toHaveBeenCalled(); }); it('renders the empty-state message ONLY on a successful empty array', () => { diff --git a/src/redux/reducers/accessEvents.js b/src/redux/reducers/accessEvents.js index a0ad6c2f..bb48a816 100644 --- a/src/redux/reducers/accessEvents.js +++ b/src/redux/reducers/accessEvents.js @@ -1,4 +1,3 @@ -/* eslint-disable */ import api from '../../utils/api'; import { FETCH_ACCESS_EVENTS_REQUEST, diff --git a/src/translations/accessEvents.i18n.test.js b/src/translations/accessEvents.i18n.test.js index 2ca24956..5c85aa05 100644 --- a/src/translations/accessEvents.i18n.test.js +++ b/src/translations/accessEvents.i18n.test.js @@ -9,9 +9,16 @@ describe('domain.accessEvents.* i18n keys', () => { const expectedKeys = [ 'domain.accessEvents.accessedAt', 'domain.accessEvents.category', + // One label per RdapPrivilegeGrant::CATEGORIES value, so the panel never shows the raw + // enum. A category the registry adds later falls back to its raw value by design. + 'domain.accessEvents.category.cert', + 'domain.accessEvents.category.eis_internal', + 'domain.accessEvents.category.police', + 'domain.accessEvents.category.ria', 'domain.accessEvents.empty', 'domain.accessEvents.error', 'domain.accessEvents.institution', + 'domain.accessEvents.institutionUnknown', 'domain.accessEvents.retry', 'domain.accessEvents.title', 'domain.accessEvents.tooltip', diff --git a/src/translations/en.json b/src/translations/en.json index 78d4c559..2a0da4d6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -27,9 +27,14 @@ "domain.404.title": "Domain not found", "domain.accessEvents.accessedAt": "Access time", "domain.accessEvents.category": "Category", + "domain.accessEvents.category.cert": "CERT", + "domain.accessEvents.category.eis_internal": "EIS internal", + "domain.accessEvents.category.police": "Police", + "domain.accessEvents.category.ria": "RIA", "domain.accessEvents.empty": "No authority has accessed this domain's data.", "domain.accessEvents.error": "The access history could not be loaded. Please try again.", "domain.accessEvents.institution": "Institution", + "domain.accessEvents.institutionUnknown": "Not specified", "domain.accessEvents.retry": "Try again", "domain.accessEvents.title": "Who accessed my data", "domain.accessEvents.tooltip": "This list shows which authorities have accessed this domain's registration data. Only the institution, the category of access and the access time are shown. Recent accesses may appear after a short delay.", diff --git a/src/translations/et.json b/src/translations/et.json index b8e0ec8d..3f689cf4 100644 --- a/src/translations/et.json +++ b/src/translations/et.json @@ -27,9 +27,14 @@ "domain.404.title": "Domeeni ei leitud", "domain.accessEvents.accessedAt": "Vaatamise aeg", "domain.accessEvents.category": "Kategooria", + "domain.accessEvents.category.cert": "CERT", + "domain.accessEvents.category.eis_internal": "EIS sisemine", + "domain.accessEvents.category.police": "Politsei", + "domain.accessEvents.category.ria": "RIA", "domain.accessEvents.empty": "Ükski asutus ei ole selle domeeni andmeid vaadanud.", "domain.accessEvents.error": "Vaatamiste ajalugu ei õnnestunud laadida. Palun proovi uuesti.", "domain.accessEvents.institution": "Asutus", + "domain.accessEvents.institutionUnknown": "Määramata", "domain.accessEvents.retry": "Proovi uuesti", "domain.accessEvents.title": "Kes on minu andmeid vaadanud", "domain.accessEvents.tooltip": "See loend näitab, millised asutused on selle domeeni registreerimisandmeid vaadanud. Kuvatakse ainult asutus, vaatamise kategooria ja aeg. Hiljutised vaatamised võivad ilmuda väikese viivitusega.",