+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {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 || (
+
+ )}
+
+
+ {' '}
+ {isKnownCategory(event.category) ? (
+
+ ) : (
+ event.category
+ )}
+
+ {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 0000000..2203ad1
--- /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 0000000..8ea1b91
--- /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 f04a4dd..8a0b7c8 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 0bec267..af101bc 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 915b449..a8e9a78 100644
--- a/src/pages/DomainPage/DomainPage.jsx
+++ b/src/pages/DomainPage/DomainPage.jsx
@@ -24,6 +24,7 @@ import {
PageMessage,
MainLayout,
WhoIsConfirmDialog,
+ DomainAccessEvents,
} from '../../components';
import domainStatuses from '../../utils/domainStatuses.json';
import {
@@ -33,13 +34,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 +56,20 @@ const DomainPage = ({
const { id } = useParams();
const domain = domains[id];
const { uiElemSize } = ui;
+ // 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];
+ // 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);
@@ -75,6 +93,16 @@ 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 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 && isDomainRegistrant && accessEventsRecord === undefined) {
+ fetchAccessEvents(id);
+ }
+ }, [domain, isDomainRegistrant, accessEventsRecord, fetchAccessEvents, id]);
+
useEffect(() => {
if (registrantContacts?.ident?.type === 'org') {
if (companies.isLoading === null) {
@@ -594,6 +622,15 @@ const DomainPage = ({
+ {isDomainRegistrant ? (
+ fetchAccessEvents(id)}
+ uiElemSize={uiElemSize}
+ />
+ ) : null}
{
};
const mapStateToProps = (state) => ({
+ accessEvents: state.accessEvents.byUuid,
companies: state.companies,
contacts: state.contacts.data,
error: state.domains.error,
@@ -724,6 +762,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 ae5e8ca..9f564d8 100644
--- a/src/pages/DomainPage/DomainPage.test.jsx
+++ b/src/pages/DomainPage/DomainPage.test.jsx
@@ -47,6 +47,15 @@ vi.mock('../../redux/reducers/domains', () => ({
}),
}));
+// 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: mockFetchAccessEvents,
+}));
+
const createTestStore = (overrides = {}) => {
const baseState = {
ui: {
@@ -92,6 +101,9 @@ const createTestStore = (overrides = {}) => {
isLoading: null,
message: null,
},
+ accessEvents: {
+ byUuid: {},
+ },
};
// Create reducers that handle domain actions
@@ -129,6 +141,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 +302,193 @@ 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: 'police',
+ },
+ {
+ accessed_at: '2026-07-09T09:30:00+03:00',
+ organization: null,
+ category: 'cert',
+ },
+ ];
+
+ it('renders exactly the three returned fields and no withheld data', () => {
+ const eventsStore = createTestStore({
+ accessEvents: {
+ byUuid: {
+ [mockDomain.id]: {
+ events: sampleEvents,
+ isLoading: false,
+ error: false,
+ },
+ },
+ },
+ });
+
+ const { container } = render(
+
+
+
+ );
+
+ // 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.querySelectorAll('[data-test="access-events-row"]')).toHaveLength(2);
+
+ // 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('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(
+
+
+
+ );
+
+ expect(container.textContent).not.toContain('Kes on minu andmeid vaadanud');
+ expect(container.textContent).not.toContain(
+ 'Ükski asutus ei ole selle domeeni andmeid vaadanud.'
+ );
+ // 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', () => {
+ const emptyStore = createTestStore({
+ accessEvents: {
+ byUuid: {
+ [mockDomain.id]: {
+ events: [],
+ isLoading: false,
+ error: false,
+ },
+ },
+ },
+ });
+
+ const { container } = render(
+
+
+
+ );
+
+ expect(container.textContent).toContain('Kes on minu andmeid vaadanud');
+ expect(container.textContent).toContain(
+ 'Ü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/actions/index.js b/src/redux/actions/index.js
index b75dd99..b4925e1 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 0000000..bb48a81
--- /dev/null
+++ b/src/redux/reducers/accessEvents.js
@@ -0,0 +1,89 @@
+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.
+//
+// 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 },
+ 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)));
+};
+
+// 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 = {
+ 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 withRecord(state, payload.uuid, {
+ ...recordFor(state, payload.uuid),
+ isLoading: true,
+ error: false,
+ });
+
+ case FETCH_ACCESS_EVENTS_SUCCESS:
+ return withRecord(state, payload.uuid, {
+ events: payload.events,
+ isLoading: false,
+ error: false,
+ });
+
+ case FETCH_ACCESS_EVENTS_FAILURE:
+ return withRecord(state, payload.uuid, {
+ ...recordFor(state, payload.uuid),
+ 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 0000000..8364d22
--- /dev/null
+++ b/src/redux/reducers/accessEvents.test.js
@@ -0,0 +1,158 @@
+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', () => {
+ const otherUuid = 'aaaaaaaa-1111-2222-3333-444444444444';
+
+ it('returns initial state', () => {
+ expect(reducer(undefined, {})).toEqual(initialState);
+ });
+
+ it('handles FETCH_ACCESS_EVENTS_REQUEST scoped to the uuid', () => {
+ const state = reducer(initialState, {
+ type: 'FETCH_ACCESS_EVENTS_REQUEST',
+ payload: { uuid },
+ });
+ 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', () => {
+ const state = reducer(initialState, {
+ type: 'FETCH_ACCESS_EVENTS_SUCCESS',
+ payload: { uuid, events: sampleEvents },
+ });
+ expect(state.byUuid[uuid].events).toEqual(sampleEvents);
+ expect(state.byUuid[uuid].isLoading).toBe(false);
+ expect(state.byUuid[uuid].error).toBe(false);
+ });
+
+ it('preserves records for other uuids on a new success', () => {
+ 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.byUuid[otherUuid].events).toEqual([]);
+ expect(second.byUuid[uuid].events).toEqual(sampleEvents);
+ });
+
+ it('handles FETCH_ACCESS_EVENTS_FAILURE scoped to the uuid', () => {
+ const state = reducer(initialState, {
+ type: 'FETCH_ACCESS_EVENTS_FAILURE',
+ payload: { uuid },
+ });
+ 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', () => {
+ 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 6aa4602..4b2f70a 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 0000000..5c85aa0
--- /dev/null
+++ b/src/translations/accessEvents.i18n.test.js
@@ -0,0 +1,54 @@
+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',
+ // 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',
+ ].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 f26dc55..2a0da4d 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -25,6 +25,19 @@
"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.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.",
"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 00f9a04..3f689cf 100644
--- a/src/translations/et.json
+++ b/src/translations/et.json
@@ -25,6 +25,19 @@
"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.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.",
"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 22e4f0b..b4ecb7b 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`),
};