diff --git a/frontend/src/api/__tests__/users.migrationReports.spec.ts b/frontend/src/api/__tests__/users.migrationReports.spec.ts new file mode 100644 index 00000000..c9375cd4 --- /dev/null +++ b/frontend/src/api/__tests__/users.migrationReports.spec.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { get, post } = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), +})) + +vi.mock('@/api/client', () => ({ + apiClient: { + get, + post, + }, +})) + +import { + getAuthIdentityMigrationReportSummary, + listAuthIdentityMigrationReports, + resolveAuthIdentityMigrationReport, +} from '@/api/admin/users' + +describe('admin users auth identity migration reports API', () => { + beforeEach(() => { + get.mockReset() + post.mockReset() + }) + + it('lists migration reports with pagination and report type filter', async () => { + const response = { + items: [], + total: 0, + page: 2, + page_size: 10, + pages: 0, + } + get.mockResolvedValue({ data: response }) + + const result = await listAuthIdentityMigrationReports({ + page: 2, + pageSize: 10, + reportType: 'oidc_synthetic_email_requires_manual_recovery', + }) + + expect(get).toHaveBeenCalledWith('/admin/users/auth-identity-migration-reports', { + params: { + page: 2, + page_size: 10, + report_type: 'oidc_synthetic_email_requires_manual_recovery', + }, + }) + expect(result).toBe(response) + }) + + it('loads migration report summary', async () => { + const response = { + total: 2, + open_total: 1, + resolved_total: 1, + by_type: { + oidc_synthetic_email_requires_manual_recovery: 2, + }, + } + get.mockResolvedValue({ data: response }) + + const result = await getAuthIdentityMigrationReportSummary() + + expect(get).toHaveBeenCalledWith('/admin/users/auth-identity-migration-reports/summary') + expect(result).toBe(response) + }) + + it('submits report resolution note', async () => { + const response = { + id: 7, + resolution_note: 'resolved by admin', + } + post.mockResolvedValue({ data: response }) + + const result = await resolveAuthIdentityMigrationReport(7, 'resolved by admin') + + expect(post).toHaveBeenCalledWith('/admin/users/auth-identity-migration-reports/7/resolve', { + resolution_note: 'resolved by admin', + }) + expect(result).toBe(response) + }) +}) diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 39cb1dfa..2d154cf8 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -6,6 +6,30 @@ import { apiClient } from '../client' import type { AdminUser, UpdateUserRequest, PaginatedResponse, ApiKey } from '@/types' +export interface AuthIdentityMigrationReport { + id: number + report_type: string + report_key: string + details: Record + created_at: string + resolved_at?: string | null + resolved_by_user_id?: number | null + resolution_note?: string +} + +export interface AuthIdentityMigrationReportSummary { + total: number + open_total: number + resolved_total: number + by_type: Record +} + +export interface ListAuthIdentityMigrationReportsParams { + page?: number + pageSize?: number + reportType?: string +} + /** * List all users with pagination * @param page - Page number (default: 1) @@ -248,6 +272,42 @@ export async function replaceGroup( return data } +export async function getAuthIdentityMigrationReportSummary(): Promise { + const { data } = await apiClient.get( + '/admin/users/auth-identity-migration-reports/summary' + ) + return data +} + +export async function listAuthIdentityMigrationReports( + params: ListAuthIdentityMigrationReportsParams = {} +): Promise> { + const { data } = await apiClient.get>( + '/admin/users/auth-identity-migration-reports', + { + params: { + page: params.page ?? 1, + page_size: params.pageSize ?? 20, + report_type: params.reportType ?? '' + } + } + ) + return data +} + +export async function resolveAuthIdentityMigrationReport( + id: number, + resolutionNote: string +): Promise { + const { data } = await apiClient.post( + `/admin/users/auth-identity-migration-reports/${id}/resolve`, + { + resolution_note: resolutionNote + } + ) + return data +} + export const usersAPI = { list, getById, @@ -260,7 +320,10 @@ export const usersAPI = { getUserApiKeys, getUserUsageStats, getUserBalanceHistory, - replaceGroup + replaceGroup, + getAuthIdentityMigrationReportSummary, + listAuthIdentityMigrationReports, + resolveAuthIdentityMigrationReport } export default usersAPI diff --git a/frontend/src/views/admin/AuthIdentityMigrationReportsView.vue b/frontend/src/views/admin/AuthIdentityMigrationReportsView.vue new file mode 100644 index 00000000..5aeb6b28 --- /dev/null +++ b/frontend/src/views/admin/AuthIdentityMigrationReportsView.vue @@ -0,0 +1,473 @@ + + + diff --git a/frontend/src/views/admin/__tests__/AuthIdentityMigrationReportsView.spec.ts b/frontend/src/views/admin/__tests__/AuthIdentityMigrationReportsView.spec.ts new file mode 100644 index 00000000..5e6b0ae0 --- /dev/null +++ b/frontend/src/views/admin/__tests__/AuthIdentityMigrationReportsView.spec.ts @@ -0,0 +1,243 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent, h } from 'vue' + +import AuthIdentityMigrationReportsView from '../AuthIdentityMigrationReportsView.vue' + +const { getAuthIdentityMigrationReportSummary, listAuthIdentityMigrationReports, resolveAuthIdentityMigrationReport } = vi.hoisted(() => ({ + getAuthIdentityMigrationReportSummary: vi.fn(), + listAuthIdentityMigrationReports: vi.fn(), + resolveAuthIdentityMigrationReport: vi.fn(), +})) + +const { showError, showSuccess } = vi.hoisted(() => ({ + showError: vi.fn(), + showSuccess: vi.fn(), +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + users: { + getAuthIdentityMigrationReportSummary, + listAuthIdentityMigrationReports, + resolveAuthIdentityMigrationReport, + }, + }, +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showError, + showSuccess, + }), +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + locale: { value: 'en' }, + t: (key: string) => key, + }), + } +}) + +vi.mock('@/utils/format', () => ({ + formatDateTime: (value: string | null | undefined) => value ?? '', +})) + +const sampleReport = { + id: 1, + report_type: 'oidc_synthetic_email_requires_manual_recovery', + report_key: 'legacy@example.invalid', + details: { + user_id: 42, + legacy_email: 'legacy@example.invalid', + provider_key: 'https://issuer.example', + provider_subject: 'subject-123', + }, + created_at: '2026-04-20T01:02:03Z', + resolved_at: null, + resolved_by_user_id: null, + resolution_note: '', +} + +const summaryResponse = { + total: 2, + open_total: 1, + resolved_total: 1, + by_type: { + oidc_synthetic_email_requires_manual_recovery: 2, + }, +} + +const listResponse = { + items: [sampleReport], + total: 1, + page: 1, + page_size: 20, + pages: 1, +} + +const AppLayoutStub = defineComponent({ + setup(_, { slots }) { + return () => h('div', slots.default?.()) + }, +}) + +const TablePageLayoutStub = defineComponent({ + setup(_, { slots }) { + return () => h('div', [ + slots.actions?.(), + slots.filters?.(), + slots.table?.(), + slots.default?.(), + slots.pagination?.(), + ]) + }, +}) + +const DataTableStub = defineComponent({ + props: { + columns: { type: Array, default: () => [] }, + data: { type: Array, default: () => [] }, + loading: { type: Boolean, default: false }, + }, + setup(props, { slots }) { + return () => h('div', { 'data-test': 'data-table' }, [ + props.loading + ? h('div', 'loading') + : (props.data as Array>).map((row) => + h( + 'div', + { key: String(row.id ?? row.report_key) }, + (props.columns as Array<{ key: string }>).map((column) => { + const slot = slots[`cell-${column.key}`] + return h( + 'div', + { key: column.key, [`data-test-cell`]: `${String(row.id)}-${column.key}` }, + slot + ? slot({ row, value: row[column.key] }) + : String(row[column.key] ?? '') + ) + }) + ) + ), + ]) + }, +}) + +const PaginationStub = defineComponent({ + props: { + total: { type: Number, required: true }, + page: { type: Number, required: true }, + pageSize: { type: Number, required: true }, + }, + emits: ['update:page', 'update:pageSize'], + setup(props, { emit }) { + return () => h('div', { 'data-test': 'pagination' }, [ + h('button', { + type: 'button', + 'data-test': 'next-page', + onClick: () => emit('update:page', props.page + 1), + }, 'next'), + h('button', { + type: 'button', + 'data-test': 'page-size-50', + onClick: () => emit('update:pageSize', 50), + }, '50'), + ]) + }, +}) + +describe('AuthIdentityMigrationReportsView', () => { + beforeEach(() => { + getAuthIdentityMigrationReportSummary.mockReset() + listAuthIdentityMigrationReports.mockReset() + resolveAuthIdentityMigrationReport.mockReset() + showError.mockReset() + showSuccess.mockReset() + + getAuthIdentityMigrationReportSummary.mockResolvedValue(summaryResponse) + listAuthIdentityMigrationReports.mockResolvedValue(listResponse) + resolveAuthIdentityMigrationReport.mockResolvedValue({ + ...sampleReport, + resolved_at: '2026-04-20T02:00:00Z', + resolved_by_user_id: 100, + resolution_note: 'resolved by admin', + }) + }) + + const mountView = () => + mount(AuthIdentityMigrationReportsView, { + global: { + stubs: { + AppLayout: AppLayoutStub, + TablePageLayout: TablePageLayoutStub, + DataTable: DataTableStub, + Pagination: PaginationStub, + Icon: true, + }, + }, + }) + + it('loads summary and first page of reports on mount', async () => { + const wrapper = mountView() + + await flushPromises() + + expect(getAuthIdentityMigrationReportSummary).toHaveBeenCalledTimes(1) + expect(listAuthIdentityMigrationReports).toHaveBeenCalledWith({ + page: 1, + pageSize: 20, + reportType: '', + }) + expect(wrapper.get('[data-test="summary-total"]').text()).toContain('2') + expect(wrapper.get('[data-test="summary-open"]').text()).toContain('1') + expect(wrapper.get('[data-test="summary-resolved"]').text()).toContain('1') + expect(wrapper.text()).toContain('legacy@example.invalid') + }) + + it('reloads list when the report type filter changes', async () => { + const wrapper = mountView() + + await flushPromises() + + listAuthIdentityMigrationReports.mockClear() + + await wrapper.get('[data-test="report-type-filter"]').setValue( + 'oidc_synthetic_email_requires_manual_recovery' + ) + await flushPromises() + + expect(listAuthIdentityMigrationReports).toHaveBeenCalledWith({ + page: 1, + pageSize: 20, + reportType: 'oidc_synthetic_email_requires_manual_recovery', + }) + }) + + it('submits resolve note for the selected report and refreshes data', async () => { + const wrapper = mountView() + + await flushPromises() + + getAuthIdentityMigrationReportSummary.mockClear() + listAuthIdentityMigrationReports.mockClear() + + await wrapper.get('[data-test="select-report-1"]').trigger('click') + await wrapper.get('[data-test="resolution-note"]').setValue('resolved by admin') + await wrapper.get('[data-test="resolve-submit"]').trigger('click') + await flushPromises() + + expect(resolveAuthIdentityMigrationReport).toHaveBeenCalledWith(1, 'resolved by admin') + expect(showSuccess).toHaveBeenCalled() + expect(getAuthIdentityMigrationReportSummary).toHaveBeenCalledTimes(1) + expect(listAuthIdentityMigrationReports).toHaveBeenCalledWith({ + page: 1, + pageSize: 20, + reportType: '', + }) + }) +})