Add reviewed duplicate account consolidation and prevent duplicate imports
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
import { FEATURES, type FeatureAccess } from '../../lib/features'
|
||||
import type { Row } from './IdentityReviewPanel'
|
||||
|
||||
type Account = { id: number; username: string; email: string | null; profile_id: number | null; last_login_at: string | null }
|
||||
type Preview = {
|
||||
accounts: Account[]; keep_id: number; recommended_id: number; revision: string; can_confirm: boolean; issues: string[]
|
||||
proposed: Account & { jellyfin_user_id: string; seerr_user_id: number; features: FeatureAccess; expires_at: string | null; is_blocked: boolean; auto_search_enabled: boolean }
|
||||
}
|
||||
|
||||
export default function DuplicateAccountRepair({ row, onClose, onSaved }: { row: Row; onClose: () => void; onSaved: () => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
const [preview, setPreview] = useState<Preview | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const submit = async (confirm = false, keepId?: number) => {
|
||||
const abort = new AbortController()
|
||||
controller.current?.abort(); controller.current = abort
|
||||
setError(''); setAcknowledged(false)
|
||||
if (confirm) setSaving(true)
|
||||
else setBusy(true)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/admin/identities/duplicates/${confirm ? 'confirm' : 'check'}`, {
|
||||
method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: row.user.id, ...(keepId ? { keep_id: keepId } : {}), ...(confirm ? { keep_id: preview?.keep_id, revision: preview?.revision } : {}) }),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Could not review these accounts.')
|
||||
if (!abort.signal.aborted) { if (confirm) onSaved(); else setPreview(data) }
|
||||
} catch (err) { if (!abort.signal.aborted) { setError(err instanceof Error ? err.message : 'Repair failed. Preview again.'); setPreview(null) } }
|
||||
finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } }
|
||||
}
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null
|
||||
const overflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'; dialog.current?.showModal()
|
||||
void submit()
|
||||
return () => { controller.current?.abort(); document.body.style.overflow = overflow; previous?.focus() }
|
||||
}, [])
|
||||
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="duplicates-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}>
|
||||
<div className="identity-resolve-content">
|
||||
<header><h2 id="duplicates-title">Repair duplicate accounts</h2><button type="button" className="ghost-button" disabled={saving} onClick={onClose}>Close</button></header>
|
||||
<p>Review the Magent accounts for <strong>{row.user.username}</strong>. This repair keeps one account linked to the verified Jellyfin identity.</p>
|
||||
{busy && <p role="status">Checking live service IDs and duplicate ownership...</p>}
|
||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||
{!preview && !busy && <button type="button" disabled={saving} onClick={() => void submit()}>Check again</button>}
|
||||
{preview && <section className="identity-confirm-panel" aria-label="Duplicate repair preview">
|
||||
<label>Magent account to keep<select disabled={busy || saving} value={preview.keep_id} onChange={(event) => void submit(false, Number(event.target.value))}>
|
||||
{preview.accounts.map((account) => <option key={account.id} value={account.id}>{account.username} — Magent {account.id}{account.id === preview.recommended_id ? ' (recommended)' : ''}</option>)}
|
||||
</select></label>
|
||||
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p>
|
||||
<div className="identity-mapping">{preview.accounts.map((account) => <div key={account.id}><strong>Magent {account.id}{account.id === preview.keep_id ? ' · Keep' : ' · Consolidate'}</strong><p>{account.username}</p><p>{account.email || 'No email'} · Profile {account.profile_id ?? 'None'}</p><p>Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : 'Never'}</p></div>)}</div>
|
||||
<h3>Resulting account</h3>
|
||||
<p><strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr {preview.proposed.seerr_user_id ?? 'Not verified'}</p>
|
||||
<p>Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? 'Not verified'}</code></p>
|
||||
<p>Email: {preview.proposed.email || 'None'} · Profile: {preview.proposed.profile_id ?? 'None'}</p>
|
||||
<p>Access: {preview.proposed.is_blocked ? 'Blocked' : 'Not blocked'} · Expiry: {preview.proposed.expires_at ? new Date(preview.proposed.expires_at).toLocaleString() : 'None'} · Automatic search: {preview.proposed.auto_search_enabled ? 'Enabled' : 'Disabled'}</p>
|
||||
<ul>{FEATURES.map((feature) => <li key={feature.key}>{feature.label}: {preview.proposed.features[feature.key] ? 'Enabled' : 'Disabled'}</li>)}</ul>
|
||||
<p>Request, issue, invitation and login activity history is retained. The selected account keeps its email and profile. Any block, earlier expiry or disabled permission on either row is preserved.</p>
|
||||
<p>Extra Magent rows are removed from the active directory after their details are archived. Their outstanding emails are cancelled and their email subscriptions are not inherited. The kept account retains its own subscriptions where still eligible. Password reset links must be requested again.</p>
|
||||
<p>Jellyfin, Seerr and Jellystat accounts and media are unchanged. This action does not merge different Jellyfin identities or delete upstream users.</p>
|
||||
{preview.issues.length > 0 && <ul className="identity-issues">{preview.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
||||
<label className="identity-import-option"><span><input type="checkbox" checked={acknowledged} disabled={busy || saving || !preview.can_confirm} onChange={(event) => setAcknowledged(event.target.checked)} /> I confirm these rows belong to the same person and have reviewed the account to keep.</span></label>
|
||||
<button type="button" disabled={!preview.can_confirm || !acknowledged || busy || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and repairing...' : 'Confirm duplicate repair'}</button>
|
||||
</section>}
|
||||
</div>
|
||||
</dialog>
|
||||
}
|
||||
Reference in New Issue
Block a user