fix profile activity and migration remediation
This commit is contained in:
@@ -13,6 +13,7 @@ vi.mock('@/api/client', () => ({
|
||||
}))
|
||||
|
||||
import {
|
||||
bindUserAuthIdentity,
|
||||
getAuthIdentityMigrationReportSummary,
|
||||
listAuthIdentityMigrationReports,
|
||||
resolveAuthIdentityMigrationReport,
|
||||
@@ -81,4 +82,31 @@ describe('admin users auth identity migration reports API', () => {
|
||||
})
|
||||
expect(result).toBe(response)
|
||||
})
|
||||
|
||||
it('binds a canonical auth identity to a user for remediation', async () => {
|
||||
const response = {
|
||||
identity_id: 11,
|
||||
provider_type: 'oidc',
|
||||
provider_key: 'https://issuer.example',
|
||||
provider_subject: 'subject-123',
|
||||
}
|
||||
post.mockResolvedValue({ data: response })
|
||||
|
||||
const result = await bindUserAuthIdentity(42, {
|
||||
provider_type: 'oidc',
|
||||
provider_key: 'https://issuer.example',
|
||||
provider_subject: 'subject-123',
|
||||
issuer: 'https://issuer.example',
|
||||
metadata: { source: 'migration-report' },
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/users/42/auth-identities', {
|
||||
provider_type: 'oidc',
|
||||
provider_key: 'https://issuer.example',
|
||||
provider_subject: 'subject-123',
|
||||
issuer: 'https://issuer.example',
|
||||
metadata: { source: 'migration-report' },
|
||||
})
|
||||
expect(result).toBe(response)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,30 @@ export interface AuthIdentityMigrationReportSummary {
|
||||
by_type: Record<string, number>
|
||||
}
|
||||
|
||||
export interface AdminBindAuthIdentityChannelRequest {
|
||||
channel: string
|
||||
channel_app_id?: string
|
||||
channel_subject: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AdminBindAuthIdentityRequest {
|
||||
provider_type: string
|
||||
provider_key: string
|
||||
provider_subject: string
|
||||
issuer?: string
|
||||
metadata?: Record<string, unknown>
|
||||
channel?: AdminBindAuthIdentityChannelRequest
|
||||
}
|
||||
|
||||
export interface AdminBoundAuthIdentity {
|
||||
identity_id: number
|
||||
provider_type: string
|
||||
provider_key: string
|
||||
provider_subject: string
|
||||
channel_id?: number | null
|
||||
}
|
||||
|
||||
export interface ListAuthIdentityMigrationReportsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
@@ -308,6 +332,17 @@ export async function resolveAuthIdentityMigrationReport(
|
||||
return data
|
||||
}
|
||||
|
||||
export async function bindUserAuthIdentity(
|
||||
userId: number,
|
||||
input: AdminBindAuthIdentityRequest
|
||||
): Promise<AdminBoundAuthIdentity> {
|
||||
const { data } = await apiClient.post<AdminBoundAuthIdentity>(
|
||||
`/admin/users/${userId}/auth-identities`,
|
||||
input
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const usersAPI = {
|
||||
list,
|
||||
getById,
|
||||
@@ -321,6 +356,7 @@ export const usersAPI = {
|
||||
getUserUsageStats,
|
||||
getUserBalanceHistory,
|
||||
replaceGroup,
|
||||
bindUserAuthIdentity,
|
||||
getAuthIdentityMigrationReportSummary,
|
||||
listAuthIdentityMigrationReports,
|
||||
resolveAuthIdentityMigrationReport
|
||||
|
||||
@@ -62,6 +62,8 @@ vi.mock('vue-i18n', async (importOriginal) => {
|
||||
if (key === 'profile.authBindings.providers.linuxdo') return 'LinuxDo'
|
||||
if (key === 'profile.authBindings.providers.wechat') return 'WeChat'
|
||||
if (key === 'profile.authBindings.providers.oidc') return params?.providerName || 'OIDC'
|
||||
if (key === 'profile.authBindings.source.avatar') return `Avatar synced from ${params?.providerName || 'provider'}`
|
||||
if (key === 'profile.authBindings.source.username') return `Username synced from ${params?.providerName || 'provider'}`
|
||||
if (key === 'common.save') return 'Save'
|
||||
if (key === 'common.delete') return 'Delete'
|
||||
return key
|
||||
@@ -169,4 +171,29 @@ describe('ProfileInfoCard', () => {
|
||||
expect(authStoreState.user?.avatar_url).toBeNull()
|
||||
expect(showSuccessMock).toHaveBeenCalledWith('Avatar removed')
|
||||
})
|
||||
|
||||
it('renders third-party source hints from profile_sources', () => {
|
||||
authStoreState.user = createUser({
|
||||
avatar_url: 'https://cdn.example.com/linuxdo.png',
|
||||
profile_sources: {
|
||||
avatar: { provider: 'linuxdo', source: 'linuxdo' },
|
||||
username: { provider: 'linuxdo', source: 'linuxdo' }
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(ProfileInfoCard, {
|
||||
props: {
|
||||
user: authStoreState.user
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: true,
|
||||
ProfileIdentityBindingsSection: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Avatar synced from LinuxDo')
|
||||
expect(wrapper.text()).toContain('Username synced from LinuxDo')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -238,6 +238,83 @@
|
||||
{{ resolving ? copy.resolving : copy.resolveAction }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-gray-200 pt-6 dark:border-dark-700">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ copy.remediationTitle }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ copy.remediationSubtitle }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div>
|
||||
<label class="input-label" for="remediation-user-id">{{ copy.remediationUserID }}</label>
|
||||
<input
|
||||
id="remediation-user-id"
|
||||
v-model="remediation.userID"
|
||||
data-test="remediation-user-id"
|
||||
class="input"
|
||||
:disabled="!selectedReport || Boolean(selectedReport.resolved_at) || binding"
|
||||
inputmode="numeric"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label" for="remediation-provider-type">{{ copy.remediationProviderType }}</label>
|
||||
<input
|
||||
id="remediation-provider-type"
|
||||
v-model="remediation.providerType"
|
||||
data-test="remediation-provider-type"
|
||||
class="input"
|
||||
:disabled="!selectedReport || Boolean(selectedReport.resolved_at) || binding"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label" for="remediation-provider-key">{{ copy.remediationProviderKey }}</label>
|
||||
<input
|
||||
id="remediation-provider-key"
|
||||
v-model="remediation.providerKey"
|
||||
data-test="remediation-provider-key"
|
||||
class="input"
|
||||
:disabled="!selectedReport || Boolean(selectedReport.resolved_at) || binding"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label" for="remediation-provider-subject">{{ copy.remediationProviderSubject }}</label>
|
||||
<input
|
||||
id="remediation-provider-subject"
|
||||
v-model="remediation.providerSubject"
|
||||
data-test="remediation-provider-subject"
|
||||
class="input"
|
||||
:disabled="!selectedReport || Boolean(selectedReport.resolved_at) || binding"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label" for="remediation-issuer">{{ copy.remediationIssuer }}</label>
|
||||
<input
|
||||
id="remediation-issuer"
|
||||
v-model="remediation.issuer"
|
||||
data-test="remediation-issuer"
|
||||
class="input"
|
||||
:disabled="!selectedReport || Boolean(selectedReport.resolved_at) || binding"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary w-full"
|
||||
data-test="remediation-submit"
|
||||
:disabled="!canBindRemediation"
|
||||
@click="submitRemediationBinding"
|
||||
>
|
||||
{{ binding ? copy.remediationSubmitting : copy.remediationAction }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -249,6 +326,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type {
|
||||
AdminBindAuthIdentityRequest,
|
||||
AuthIdentityMigrationReport,
|
||||
AuthIdentityMigrationReportSummary,
|
||||
} from '@/api/admin/users'
|
||||
@@ -294,6 +372,15 @@ const copy = computed(() => ({
|
||||
resolvePlaceholder: text('填写本次处理动作、用户沟通结果或后续追踪信息。', 'Describe the action taken, user communication, or follow-up context.'),
|
||||
resolveAction: text('提交 Resolve', 'Submit resolve'),
|
||||
resolving: text('提交中...', 'Submitting...'),
|
||||
remediationTitle: text('修复绑定', 'Remediation binding'),
|
||||
remediationSubtitle: text('可直接把迁移报告中的身份信息绑定到指定用户;已识别字段会自动预填。', 'Bind the migrated identity directly to a user. Recognized fields are prefilled automatically.'),
|
||||
remediationUserID: text('目标用户 ID', 'Target user ID'),
|
||||
remediationProviderType: text('Provider Type', 'Provider type'),
|
||||
remediationProviderKey: text('Provider Key', 'Provider key'),
|
||||
remediationProviderSubject: text('Provider Subject', 'Provider subject'),
|
||||
remediationIssuer: text('Issuer', 'Issuer'),
|
||||
remediationAction: text('提交绑定修复', 'Submit remediation binding'),
|
||||
remediationSubmitting: text('提交中...', 'Submitting...'),
|
||||
}))
|
||||
|
||||
const summary = ref<AuthIdentityMigrationReportSummary>({
|
||||
@@ -308,6 +395,7 @@ const resolutionNote = ref('')
|
||||
const loading = ref(false)
|
||||
const summaryLoading = ref(false)
|
||||
const resolving = ref(false)
|
||||
const binding = ref(false)
|
||||
|
||||
const filters = reactive({
|
||||
reportType: '',
|
||||
@@ -319,6 +407,13 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
})
|
||||
const knownReportTypes = ref<string[]>([])
|
||||
const remediation = reactive({
|
||||
userID: '',
|
||||
providerType: '',
|
||||
providerKey: '',
|
||||
providerSubject: '',
|
||||
issuer: '',
|
||||
})
|
||||
|
||||
const columns: Column[] = [
|
||||
{ key: 'status', label: text('状态', 'Status') },
|
||||
@@ -352,6 +447,18 @@ const canResolve = computed(() =>
|
||||
)
|
||||
)
|
||||
|
||||
const canBindRemediation = computed(() =>
|
||||
Boolean(
|
||||
selectedReport.value &&
|
||||
!selectedReport.value.resolved_at &&
|
||||
remediation.userID.trim() &&
|
||||
remediation.providerType.trim() &&
|
||||
remediation.providerKey.trim() &&
|
||||
remediation.providerSubject.trim() &&
|
||||
!binding.value
|
||||
)
|
||||
)
|
||||
|
||||
const mergeKnownReportTypes = (...values: Array<string | null | undefined>) => {
|
||||
const merged = new Set(knownReportTypes.value)
|
||||
for (const value of values) {
|
||||
@@ -392,6 +499,7 @@ const loadReports = async () => {
|
||||
if (selectedReport.value) {
|
||||
const refreshed = response.items.find((report) => report.id === selectedReport.value?.id) ?? null
|
||||
selectedReport.value = refreshed
|
||||
applyRemediationDefaults(refreshed)
|
||||
resolutionNote.value = refreshed?.resolved_at
|
||||
? refreshed.resolution_note ?? ''
|
||||
: resolutionNote.value
|
||||
@@ -427,6 +535,7 @@ const handlePageSizeChange = async (pageSize: number) => {
|
||||
const selectReport = (report: AuthIdentityMigrationReport) => {
|
||||
selectedReport.value = report
|
||||
resolutionNote.value = report.resolution_note ?? ''
|
||||
applyRemediationDefaults(report)
|
||||
}
|
||||
|
||||
const formatDetailsJson = (details: Record<string, unknown>) => JSON.stringify(details ?? {}, null, 2)
|
||||
@@ -458,6 +567,63 @@ const getDetailHighlights = (details: Record<string, unknown>) => {
|
||||
.map(([key, value]) => ({ key, value: String(value) }))
|
||||
}
|
||||
|
||||
const stringDetailValue = (details: Record<string, unknown>, key: string) => {
|
||||
const value = details[key]
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
const numericDetailValue = (details: Record<string, unknown>, key: string) => {
|
||||
const value = details[key]
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(Math.trunc(value))
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const inferProviderTypeFromReport = (report: AuthIdentityMigrationReport) => {
|
||||
const explicit = stringDetailValue(report.details, 'provider_type')
|
||||
if (explicit) {
|
||||
return explicit
|
||||
}
|
||||
if (report.report_type.includes('oidc')) {
|
||||
return 'oidc'
|
||||
}
|
||||
if (report.report_type.includes('wechat')) {
|
||||
return 'wechat'
|
||||
}
|
||||
if (report.report_type.includes('linuxdo')) {
|
||||
return 'linuxdo'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const inferProviderKeyFromReport = (report: AuthIdentityMigrationReport, providerType: string) => {
|
||||
const explicit = stringDetailValue(report.details, 'provider_key')
|
||||
if (explicit) {
|
||||
return explicit
|
||||
}
|
||||
if (providerType === 'wechat') {
|
||||
return 'wechat-main'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const inferProviderSubjectFromReport = (report: AuthIdentityMigrationReport) =>
|
||||
stringDetailValue(report.details, 'provider_subject')
|
||||
|| stringDetailValue(report.details, 'subject')
|
||||
|| stringDetailValue(report.details, 'unionid')
|
||||
|
||||
const applyRemediationDefaults = (report: AuthIdentityMigrationReport | null) => {
|
||||
remediation.userID = report ? numericDetailValue(report.details, 'user_id') : ''
|
||||
remediation.providerType = report ? inferProviderTypeFromReport(report) : ''
|
||||
remediation.providerKey = report ? inferProviderKeyFromReport(report, remediation.providerType) : ''
|
||||
remediation.providerSubject = report ? inferProviderSubjectFromReport(report) : ''
|
||||
remediation.issuer = report ? stringDetailValue(report.details, 'issuer') : ''
|
||||
}
|
||||
|
||||
const submitResolve = async () => {
|
||||
if (!selectedReport.value) {
|
||||
appStore.showError(text('请先选择一条报告', 'Select a report first'))
|
||||
@@ -485,6 +651,38 @@ const submitResolve = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const submitRemediationBinding = async () => {
|
||||
if (!selectedReport.value) {
|
||||
appStore.showError(text('请先选择一条报告', 'Select a report first'))
|
||||
return
|
||||
}
|
||||
|
||||
const userID = Number.parseInt(remediation.userID.trim(), 10)
|
||||
if (!Number.isFinite(userID) || userID <= 0) {
|
||||
appStore.showError(text('请输入有效的目标用户 ID', 'Enter a valid target user ID'))
|
||||
return
|
||||
}
|
||||
|
||||
const payload: AdminBindAuthIdentityRequest = {
|
||||
provider_type: remediation.providerType.trim(),
|
||||
provider_key: remediation.providerKey.trim(),
|
||||
provider_subject: remediation.providerSubject.trim(),
|
||||
issuer: remediation.issuer.trim() || undefined,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
binding.value = true
|
||||
try {
|
||||
await adminAPI.users.bindUserAuthIdentity(userID, payload)
|
||||
appStore.showSuccess(text('修复绑定已提交', 'Remediation binding submitted'))
|
||||
} catch (error) {
|
||||
console.error('Failed to submit auth identity remediation binding:', error)
|
||||
appStore.showError(text('提交修复绑定失败', 'Failed to submit remediation binding'))
|
||||
} finally {
|
||||
binding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshAll()
|
||||
})
|
||||
|
||||
@@ -4,7 +4,13 @@ import { defineComponent, h } from 'vue'
|
||||
|
||||
import AuthIdentityMigrationReportsView from '../AuthIdentityMigrationReportsView.vue'
|
||||
|
||||
const { getAuthIdentityMigrationReportSummary, listAuthIdentityMigrationReports, resolveAuthIdentityMigrationReport } = vi.hoisted(() => ({
|
||||
const {
|
||||
bindUserAuthIdentity,
|
||||
getAuthIdentityMigrationReportSummary,
|
||||
listAuthIdentityMigrationReports,
|
||||
resolveAuthIdentityMigrationReport,
|
||||
} = vi.hoisted(() => ({
|
||||
bindUserAuthIdentity: vi.fn(),
|
||||
getAuthIdentityMigrationReportSummary: vi.fn(),
|
||||
listAuthIdentityMigrationReports: vi.fn(),
|
||||
resolveAuthIdentityMigrationReport: vi.fn(),
|
||||
@@ -18,6 +24,7 @@ const { showError, showSuccess } = vi.hoisted(() => ({
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
users: {
|
||||
bindUserAuthIdentity,
|
||||
getAuthIdentityMigrationReportSummary,
|
||||
listAuthIdentityMigrationReports,
|
||||
resolveAuthIdentityMigrationReport,
|
||||
@@ -156,6 +163,7 @@ describe('AuthIdentityMigrationReportsView', () => {
|
||||
getAuthIdentityMigrationReportSummary.mockReset()
|
||||
listAuthIdentityMigrationReports.mockReset()
|
||||
resolveAuthIdentityMigrationReport.mockReset()
|
||||
bindUserAuthIdentity.mockReset()
|
||||
showError.mockReset()
|
||||
showSuccess.mockReset()
|
||||
|
||||
@@ -167,6 +175,12 @@ describe('AuthIdentityMigrationReportsView', () => {
|
||||
resolved_by_user_id: 100,
|
||||
resolution_note: 'resolved by admin',
|
||||
})
|
||||
bindUserAuthIdentity.mockResolvedValue({
|
||||
identity_id: 77,
|
||||
provider_type: 'oidc',
|
||||
provider_key: 'https://issuer.example',
|
||||
provider_subject: 'subject-123',
|
||||
})
|
||||
})
|
||||
|
||||
const mountView = () =>
|
||||
@@ -241,6 +255,35 @@ describe('AuthIdentityMigrationReportsView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pre-fills and submits remediation binding for the selected report', async () => {
|
||||
const wrapper = mountView()
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="select-report-1"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect((wrapper.get('[data-test="remediation-user-id"]').element as HTMLInputElement).value).toBe('42')
|
||||
expect((wrapper.get('[data-test="remediation-provider-type"]').element as HTMLInputElement).value).toBe('oidc')
|
||||
expect((wrapper.get('[data-test="remediation-provider-key"]').element as HTMLInputElement).value).toBe(
|
||||
'https://issuer.example'
|
||||
)
|
||||
expect((wrapper.get('[data-test="remediation-provider-subject"]').element as HTMLInputElement).value).toBe(
|
||||
'subject-123'
|
||||
)
|
||||
|
||||
await wrapper.get('[data-test="remediation-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bindUserAuthIdentity).toHaveBeenCalledWith(42, {
|
||||
provider_type: 'oidc',
|
||||
provider_key: 'https://issuer.example',
|
||||
provider_subject: 'subject-123',
|
||||
issuer: undefined,
|
||||
metadata: {},
|
||||
})
|
||||
expect(showSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps report type filter options available from list data when summary fails', async () => {
|
||||
getAuthIdentityMigrationReportSummary.mockRejectedValueOnce(new Error('summary failed'))
|
||||
listAuthIdentityMigrationReports.mockResolvedValueOnce(listResponse)
|
||||
|
||||
Reference in New Issue
Block a user