@@ -0,0 +1,161 @@
'use client'
import { useEffect , useRef , useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch , getApiBase } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
import './identities.css'
type Identity = { id : string ; name : string }
type Row = {
user : { id : number ; username : string ; role : string ; auth_provider : string ; jellyseerr_user_id : number | null }
jellyfin : Identity | null
candidate_jellyfin_id : string | null
stored_jellyfin_id : string | null
seerr : { id : number ; name : string ; jellyfin_id : string } [ ]
jellystat : { state : string ; id? : string ; name? : string }
basis : string
issues : string [ ]
state : string
can_confirm : boolean
confirmed_at : string | null
}
type Report = {
revision : string ; checked_at : string ; server_id : string | null
services : Record < string , string >
counts : Record < string , number >
rows : Row [ ]
upstream : { platform : string ; id : string ; name : string ; jellyfin_id : string | null ; detail : string } [ ]
}
const labels : Record < string , string > = { ready : 'Ready to review' , confirmed : 'Confirmed' , conflict : 'Conflict' , unlinked : 'Missing link' , unavailable : 'Check incomplete' }
const serviceLabels : Record < string , string > = { available : 'Checked' , unavailable : 'Unavailable' , not_configured : 'Not configured' , not_checked : 'No IDs to check' }
const basisLabels : Record < string , string > = { confirmed_id : 'Confirmed Jellyfin ID' , stored_jellyfin_id : 'Stored Jellyfin ID' , stored_seerr_id : 'Seerr’ s Jellyfin ID' , suggested_username : 'Suggested from Jellyfin username — review before saving' , none : 'No identity match' }
const statsLabels : Record < string , string > = { matched : 'ID matches' , missing : 'ID not found' , unavailable : 'Could not check' , not_configured : 'Not configured' , not_checked : 'No ID to check' }
export default function IdentityReviewPage() {
const router = useRouter ( )
const [ ready , setReady ] = useState ( false )
const [ report , setReport ] = useState < Report | null > ( null )
const [ busy , setBusy ] = useState ( false )
const [ saving , setSaving ] = useState ( false )
const [ error , setError ] = useState ( '' )
const [ notice , setNotice ] = useState ( '' )
const [ query , setQuery ] = useState ( '' )
const [ filter , setFilter ] = useState ( 'all' )
const [ selected , setSelected ] = useState < number [ ] > ( [ ] )
const [ reviewing , setReviewing ] = useState ( false )
const controller = useRef < AbortController | null > ( null )
const reviewPanel = useRef < HTMLElement | null > ( null )
useEffect ( ( ) = > {
const abort = new AbortController ( )
void authFetch ( ` ${ getApiBase ( ) } /auth/me ` , { signal : abort.signal } ) . then ( async ( response ) = > {
if ( response . status === 401 ) { router . replace ( '/login' ) ; return }
if ( ! response . ok ) throw new Error ( 'Could not check administrator access. Refresh to try again.' )
if ( ( await response . json ( ) ) . role !== 'admin' ) { router . replace ( '/' ) ; return }
if ( ! abort . signal . aborted ) setReady ( true )
} ) . catch ( ( err : Error ) = > { if ( ! abort . signal . aborted ) setError ( err . message ) } )
return ( ) = > { abort . abort ( ) ; controller . current ? . abort ( ) }
} , [ router ] )
useEffect ( ( ) = > { if ( reviewing ) reviewPanel . current ? . focus ( ) } , [ reviewing ] )
const responseData = async ( response : Response ) = > {
if ( response . status === 401 ) { router . replace ( '/login' ) ; throw new Error ( 'Your session has ended. Sign in again.' ) }
if ( response . status === 403 ) { router . replace ( '/' ) ; throw new Error ( 'Administrator access is required.' ) }
const data = await response . json ( ) . catch ( ( ) = > ( { } ) )
if ( ! response . ok ) throw new Error ( typeof data . detail === 'string' ? data . detail : 'The identity check could not complete. Try again.' )
return data
}
const runCheck = async ( ) = > {
controller . current ? . abort ( )
const abort = new AbortController ( )
controller . current = abort
setBusy ( true ) ; setError ( '' ) ; setNotice ( '' ) ; setSelected ( [ ] ) ; setReviewing ( false ) ; setReport ( null )
try {
const data = await responseData ( await authFetch ( ` ${ getApiBase ( ) } /admin/identities ` , { signal : abort.signal } ) )
if ( ! abort . signal . aborted ) setReport ( data )
} catch ( err ) {
if ( ! abort . signal . aborted ) setError ( err instanceof Error ? err . message : 'Could not check identities.' )
} finally { if ( ! abort . signal . aborted ) setBusy ( false ) }
}
const save = async ( ) = > {
if ( ! report || saving || ! selected . length ) return
setSaving ( true ) ; setError ( '' ) ; setNotice ( '' )
try {
const data = await responseData ( await authFetch ( ` ${ getApiBase ( ) } /admin/identities/confirm ` , {
method : 'POST' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON.stringify ( { revision : report.revision , user_ids : selected } ) ,
} ) )
setNotice ( ` ${ data . confirmed } account ${ data . confirmed === 1 ? 'link' : 'links' } confirmed and saved. Run another check to see the updated mappings. ` )
// The scan describes the previous database state and cannot be reused for another write.
setReport ( null ) ; setSelected ( [ ] ) ; setReviewing ( false )
} catch ( err ) {
setError ( err instanceof Error ? err . message : 'Could not save identity links.' )
setReport ( null ) ; setSelected ( [ ] ) ; setReviewing ( false )
} finally { setSaving ( false ) }
}
const needle = query . trim ( ) . toLowerCase ( )
const filtered = report ? . rows . filter ( ( row ) = > ( filter === 'all' || row . state === filter ) &&
[ row . user . username , row . user . id , row . candidate_jellyfin_id , row . user . jellyseerr_user_id , . . . row . seerr . map ( ( entry ) = > entry . id ) ] . join ( ' ' ) . toLowerCase ( ) . includes ( needle ) ) ? ? [ ]
const selectedRows = report ? . rows . filter ( ( row ) = > selected . includes ( row . user . id ) ) ? ? [ ]
const eligible = filtered . filter ( ( row ) = > row . can_confirm ) . map ( ( row ) = > row . user . id )
const toggle = ( id : number ) = > { setReviewing ( false ) ; setSelected ( ( current ) = > current . includes ( id ) ? current . filter ( ( value ) = > value !== id ) : [ . . . current , id ] ) }
return < AdminShell title = "User identities" subtitle = "Review account links across Jellyfin, Seerr, Jellystat and Magent." actions = { < a href = "/users" className = "ghost-button" > Back to users < / a > } >
< div className = "identity-review" >
{ error && < p className = "error-banner" role = "alert" > { error } < / p > }
{ notice && < p className = "status-banner" role = "status" > { notice } < / p > }
{ ! ready && ! error && < p role = "status" > Checking administrator access … < / p > }
{ ready && < >
< section className = "identity-intro admin-panel" >
< div > < h2 > Confirm user IDs < / h2 > < p > Jellyfin ’ s server and user IDs identify each account . Seerr and Jellystat are checked against that same user ID . < / p > < p > Review the proposed links before saving . Duplicate and conflicting accounts need individual investigation . < / p > < / div >
< button type = "button" onClick = { runCheck } disabled = { busy || saving } > { busy ? 'Checking all accounts…' : report ? 'Run check again' : 'Check all user IDs' } < / button >
< / section >
{ busy && < p role = "status" > Reading the live user directories and checking Jellystat IDs . This can take up to a minute . < / p > }
{ report && < >
< div className = "identity-service-strip" > { Object . entries ( report . services ) . map ( ( [ service , state ] ) = > < span key = { service } > < strong > { service === 'seerr' ? 'Seerr' : service === 'jellyfin' ? 'Jellyfin' : 'Jellystat' } < / strong > { serviceLabels [ state ] ? ? state } < / span > ) } < / div >
< p className = "identity-meta" > Checked { new Date ( report . checked_at ) . toLocaleString ( ) } · Jellyfin server < code > { report . server_id ? ? 'Unavailable' } < / code > < / p >
< div className = "identity-counts" > { [ 'magent' , 'ready' , 'confirmed' , 'conflict' , 'unlinked' , 'unavailable' ] . map ( ( state ) = > < div key = { state } > < strong > { report . counts [ state ] } < / strong > < span > { state === 'magent' ? 'Magent accounts' : labels [ state ] } < / span > < / div > ) } < / div >
< p className = "identity-meta" > Includes duplicate Magent rows hidden in the user directory . Jellystat checks cover IDs found in Jellyfin , Seerr and stored Magent links ; historical Jellystat - only IDs are outside this check . < / p >
< div className = "identity-filters" >
< label > Find an account < input type = "search" value = { query } onChange = { ( event ) = > setQuery ( event . target . value ) } placeholder = "Username or user ID" disabled = { saving } / > < / label >
< label > Show < select value = { filter } onChange = { ( event ) = > setFilter ( event . target . value ) } disabled = { saving } > < option value = "all" > All accounts < / option > { Object . entries ( labels ) . map ( ( [ state , label ] ) = > < option key = { state } value = { state } > { label } < / option > ) } < / select > < / label >
< / div >
< div className = "identity-selection" >
< span > { filtered . length } accounts shown · { selected . length } selected < / span >
< button type = "button" className = "ghost-button" disabled = { saving || ! eligible . length } onClick = { ( ) = > { setSelected ( ( current ) = > [ . . . new Set ( [ . . . current , . . . eligible ] ) ] ) ; setReviewing ( false ) } } > Select ready accounts shown < / button >
< button type = "button" className = "ghost-button" disabled = { saving || ! selected . length } onClick = { ( ) = > { setSelected ( [ ] ) ; setReviewing ( false ) } } > Clear selection < / button >
< button type = "button" disabled = { saving || ! selected . length } onClick = { ( ) = > setReviewing ( true ) } > Review selected links ( { selected . length } ) < / button >
< / div >
{ reviewing && < section className = "identity-confirm-panel" ref = { reviewPanel } tabIndex = { - 1 } aria-label = "Review links before saving" >
< h2 > Save these { selected . length } account links ? < / h2 >
< p > Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below . The live IDs will be checked again before saving . < / p >
< ul > { selectedRows . map ( ( row ) = > < li key = { row . user . id } > < strong > { row . user . username } < / strong > · Magent { row . user . id } → Jellyfin < code > { row . candidate_jellyfin_id } < / code > → Seerr { row . seerr [ 0 ] . id } < / li > ) } < / ul >
< p > Saving links does not merge or delete accounts . Existing requests and playback history stay with their service IDs . < / p >
< div className = "identity-confirm-actions" > < button type = "button" onClick = { save } disabled = { saving } > { saving ? 'Rechecking and saving…' : 'Confirm and save links' } < / button > < button type = "button" className = "ghost-button" disabled = { saving } onClick = { ( ) = > setReviewing ( false ) } > Back to review < / button > < / div >
< / section > }
< section className = "identity-accounts" aria-label = "Account identity results" >
{ ! filtered . length && < p > No accounts match these filters . < / p > }
{ filtered . map ( ( row ) = > < article className = "identity-account" key = { row . user . id } >
< header > < div className = "identity-account-name" > { row . can_confirm && < input type = "checkbox" aria-label = { ` Select ${ row . user . username } (Magent ${ row . user . id } ) ` } checked = { selected . includes ( row . user . id ) } disabled = { saving } onChange = { ( ) = > toggle ( row . user . id ) } / > } < div > < h2 > { row . user . username } < / h2 > < span > Magent { row . user . id } · { row . user . auth_provider === 'jellyseerr' ? 'Seerr' : row . user . auth_provider } sign - in < / span > < / div > < / div > < span className = { ` identity-badge is- ${ row . state } ` } > { labels [ row . state ] } < / span > < / header >
< dl className = "identity-mapping" >
< div > < dt > Jellyfin user ID < / dt > < dd > < code > { row . candidate_jellyfin_id ? ? 'No match' } < / code > { row . jellyfin && < span > { row . jellyfin . name } < / span > } < small > { basisLabels [ row . basis ] } < / small > { row . stored_jellyfin_id && row . stored_jellyfin_id !== row . candidate_jellyfin_id && < small > Stored : { row . stored_jellyfin_id } < / small > } < / dd > < / div >
< div > < dt > Seerr user ID < / dt > < dd > < strong > { row . seerr . length ? row . seerr . map ( ( entry ) = > entry . id ) . join ( ', ' ) : 'No match' } < / strong > < span > { row . seerr . map ( ( entry ) = > entry . name ) . join ( ', ' ) } < / span > < small > Stored in Magent : { row . user . jellyseerr_user_id ? ? 'Not linked' } < / small > < / dd > < / div >
< div > < dt > Jellystat user ID < / dt > < dd > < code > { row . jellystat . id ? ? 'Not verified' } < / code > < span > { statsLabels [ row . jellystat . state ] ? ? row . jellystat . state } < / span > < / dd > < / div >
< / dl >
{ row . issues . length > 0 && < ul className = "identity-issues" > { row . issues . map ( ( issue ) = > < li key = { issue } > { issue } < / li > ) } < / ul > }
{ row . state === 'unlinked' && < p className = "identity-meta" > A matching account is missing in one or more services . This account cannot be confirmed yet . < / p > }
{ row . state === 'unavailable' && < p className = "identity-meta" > A required service could not be checked . Check its connection and run this again . < / p > }
{ row . confirmed_at && < p className = "identity-meta" > Last confirmed { new Date ( row . confirmed_at ) . toLocaleString ( ) } < / p > }
< / article > ) }
< / section >
{ report . upstream . length > 0 && < details className = "identity-upstream" > < summary > { report . upstream . length } upstream accounts need review < / summary > < ul > { report . upstream . map ( ( entry ) = > < li key = { ` ${ entry . platform } - ${ entry . id } ` } > < strong > { entry . platform } : { entry . name } < / strong > · ID < code > { entry . id } < / code > { entry . jellyfin_id && < span > · Jellyfin < code > { entry . jellyfin_id } < / code > < / span > } < p > { entry . detail } < / p > < / li > ) } < / ul > < / details > }
< / > }
< / > }
< / div >
< / AdminShell >
}