2026-10-14 20:35:48 +08:00
import { useCallback , useEffect , useMemo , useState } from 'react' ;
2026-10-14 19:21:35 +08:00
import CredentialsSection from './CredentialsSection' ;
import AccountLogin from './AccountLogin' ;
import ProjectsSection from './ProjectsSection' ;
import { AccountsSection , PasswordSection } from './AccountControls' ;
import { AccountRequestError as AdminRequestError , accountRequest as request } from './account-api' ;
import type { components } from '../generated/api' ;
const adminTabs = [
{ id : 'projects' , label : 'Projects' },
2026-10-14 20:35:48 +08:00
{ id : 'ingest-review' , label : 'Ingest review' },
2026-10-14 19:21:35 +08:00
{ id : 'users' , label : 'Users' },
{ id : 'credentials' , label : 'Credentials' },
{ id : 'logs' , label : 'Logs' },
{ id : 'settings' , label : 'Admin Settings' },
] as const ;
type AdminTab = typeof adminTabs [ number ][ 'id' ];
type Credential = components [ 'schemas' ][ 'CredentialSummary' ];
type Activity = components [ 'schemas' ][ 'AdminEvent' ];
type Project = components [ 'schemas' ][ 'ProjectRecord' ];
2026-10-14 20:35:48 +08:00
type IngestReview = {
review_id : string ; project_id : string ; run_id : string ; source_revision_id : string ;
extraction_id : string ; locator : { kind : string ; block_id : string ; start : number ; end : number ; page? : number | null };
excerpt : string ; lens : string ; summary : string ; relevance_rationale : string ;
jev_relation_probability? : number | null ; jev_lens? : string | null ;
jev_lens_confidence? : number | null ; jev_model? : string | null ;
2026-10-14 20:43:09 +08:00
jev_facet_scores ?: { facet : 'tags' | 'audience' | 'use_for' | 'domain' ; value : string ; probability : number }[];
2026-10-14 20:35:48 +08:00
relevance : 'borderline' | 'low' ; status : string ; created_at : string ;
};
function IngestReviewSection ({ csrf , projects , onError , onMessage } : {
csrf : string ; projects : Project []; onError : ( message : string ) => void ; onMessage : ( message : string ) => void ;
}) {
const [ projectId , setProjectId ] = useState ( '' );
const [ items , setItems ] = useState < IngestReview [] >([]);
const [ guidance , setGuidance ] = useState < Record < string , string >>({});
const [ busyId , setBusyId ] = useState ( '' );
const [ rationales , setRationales ] = useState < Record < string , string >>({});
const [ saveGuidance , setSaveGuidance ] = useState < Record < string , boolean >>({});
const refresh = useCallback ( async () => {
if ( ! projectId ) { setItems ([]); return ; }
try {
const rows = await request < IngestReview [] >( `/v1/ingest-reviews?project_id= ${ encodeURIComponent ( projectId ) } &status=needs-user-review&limit=100` , csrf );
setItems ( rows );
} catch ( failure ) { onError ( failure instanceof Error ? failure . message : 'Could not load review queue.' ); }
}, [ projectId , csrf , onError ]);
useEffect (() => { void refresh (); }, [ refresh ]);
async function decide ( item : IngestReview , decision : 'include' | 'exclude' | 'defer' ) {
const rationale = rationales [ item . review_id ] ? . trim ();
if ( ! rationale ) { onError ( 'Add a short reason for this decision.' ); return ; }
const wantsGuidance = saveGuidance [ item . review_id ] ?? false ;
const guidanceRule = guidance [ item . review_id ] ? . trim ();
if ( wantsGuidance && ! guidanceRule ) { onError ( 'Write the reusable guidance rule to save it.' ); return ; }
setBusyId ( item . review_id ); onError ( '' );
try {
await request ( `/v1/ingest-reviews/ ${ encodeURIComponent ( item . review_id ) } /decision` , csrf , {
method : 'POST' ,
body : JSON.stringify ({ decision_id : crypto.randomUUID (), review_id : item.review_id , decision , rationale ,
save_as_guidance : wantsGuidance , ...( wantsGuidance ? { guidance_rule : guidanceRule } : {}) }),
});
onMessage ( decision === 'include' ? 'Relevance confirmed. Feedback saved; knowledge changes still use the normal proposal flow.' : decision === 'exclude' ? 'Marked not relevant. Feedback saved.' : 'Feedback deferred.' );
await refresh ();
} catch ( failure ) { onError ( failure instanceof Error ? failure . message : 'Could not save review decision.' ); }
finally { setBusyId ( '' ); }
}
return < section className = "admin-card" aria-labelledby = "ingest-review-title" >
2026-10-14 20:43:09 +08:00
< div className = "admin-heading" >< div >< h2 id = "ingest-review-title" > Ingest review </ h2 >< p className = "muted-copy" > These flags are recorded alongside normal ingestion and do not hold up proposals or publication . Your decision helps tune future project triage .</ p >< p className = "muted-copy" > If configured , Jev sends the project context and excerpt to OpenRouter for relevance , lens and candidate facet suggestions . These signals are advisory .</ p ></ div >
2026-10-14 20:35:48 +08:00
< button className = "quiet-button" disabled = { busyId !== '' || ! projectId } onClick = {() => void refresh ()}> Refresh </ button ></ div >
< label > Project < select value = { projectId } onChange = { event => setProjectId ( event . target . value )}>< option value = "" > Choose a project </ option >{ projects . filter ( project => project . status !== 'archived' ). map ( project => < option key = { project . project_id } value = { project . project_id }>{ project . title }</ option >)}</ select ></ label >
{ ! projectId ? < p > Select a project to view its review flags .</ p > : items . length === 0 ? < p > No review flags need feedback .</ p > : < ol className = "admin-activity" >{ items . map ( item => < li key = { item . review_id }>
< strong >{ item . lens . replaceAll ( '-' , ' ' )} · { item . relevance } relevance · { item . summary }</ strong >
< p >{ item . excerpt }</ p >< p className = "muted-copy" >{ item . relevance_rationale }</ p >
{ item . jev_relation_probability != null ? < p className = "muted-copy" > Jev relevance : { Math . round ( item . jev_relation_probability * 100 )} % · lens : { item . jev_lens ?? 'unknown' } ({ item . jev_lens_confidence != null ? ` ${ Math . round ( item . jev_lens_confidence * 100 ) } % confidence` : 'no confidence score' }) · { item . jev_model }</ p > : < p className = "muted-copy" > Jev signal unavailable or not configured .</ p >}
2026-10-14 20:43:09 +08:00
{ !! item . jev_facet_scores ? . length && < p className = "muted-copy" > Facet suggestions : { item . jev_facet_scores . map ( score => ` ${ score . facet . replace ( '_' , ' ' ) } “ ${ score . value } ” ( ${ Math . round ( score . probability * 100 ) } %)` ). join ( ' · ' )}</ p >}
2026-10-14 20:35:48 +08:00
< p className = "muted-copy" > Source { item . source_revision_id } · { item . locator . kind . replaceAll ( '_' , ' ' )} { item . locator . page ? `· page ${ item . locator . page } ` : '' } · run { item . run_id }</ p >
< label > Decision reason < textarea maxLength = { 2000 } value = { rationales [ item . review_id ] ?? '' } onChange = { event => setRationales ( value => ({ ... value , [ item . review_id ] : event . target . value }))} /></ label >
< label >< input type = "checkbox" checked = { saveGuidance [ item . review_id ] ?? false } onChange = { event => setSaveGuidance ( value => ({ ... value , [ item . review_id ] : event . target . checked }))} /> Save a reusable rule for this project and lens </ label >
{ saveGuidance [ item . review_id ] && < label > Project guidance < textarea maxLength = { 1000 } value = { guidance [ item . review_id ] ?? '' } onChange = { event => setGuidance ( value => ({ ... value , [ item . review_id ] : event . target . value }))} placeholder = "For example: include historical show production and audience context when it informs dome practice." /></ label >}
< div className = "admin-actions" >< button disabled = { busyId !== '' } onClick = {() => void decide ( item , 'include' )}> Relevant </ button >< button className = "quiet-button" disabled = { busyId !== '' } onClick = {() => void decide ( item , 'defer' )}> Defer feedback </ button >< button className = "quiet-button" disabled = { busyId !== '' } onClick = {() => void decide ( item , 'exclude' )}> Not relevant </ button ></ div >
</ li >)}</ ol >}
</ section >;
}
2026-10-14 19:21:35 +08:00
function Admin ({ personal = false } : { personal? : boolean }) {
const [ activeTab , setActiveTab ] = useState < AdminTab >( 'projects' );
const [ passwordAccount , setPasswordAccount ] = useState ( false );
const [ csrf , setCsrf ] = useState ( '' );
const [ credentials , setCredentials ] = useState < Credential [] >([]);
const [ events , setEvents ] = useState < Activity [] >([]);
const [ logAccount , setLogAccount ] = useState ( '' );
const [ logType , setLogType ] = useState ( '' );
const [ logFrom , setLogFrom ] = useState ( '' );
const [ logTo , setLogTo ] = useState ( '' );
const [ projects , setProjects ] = useState < { project_id : string ; title : string }[] > ([]);
const [ projectRecords , setProjectRecords ] = useState < Project [] >([]);
const [ currentPrincipal , setCurrentPrincipal ] = useState ( '' );
const [ offset , setOffset ] = useState ( 0 );
const [ busy , setBusy ] = useState ( false );
const [ error , setError ] = useState ( '' );
const [ message , setMessage ] = useState ( personal ? 'Sign in to manage your agent keys.' : 'Sign in to manage accounts and service access.' );
const [ issued , setIssued ] = useState < { credential_id : string ; token : string } | null > ( null );
const load = useCallback ( async ( session : string , page = offset ) => {
const [ keys , activity , availableProjects , identity ] = await Promise . all ([
request < Credential [] >( ` ${ personal ? '/v1/me/agent-credentials' : '/v1/admin/credentials' } ?limit=50&offset= ${ page } ` , session ),
personal ? Promise . resolve ([] as Activity []) : request < Activity [] >( '/v1/admin/events?limit=50' , session ),
personal ? request < string [] >( '/v1/projects' , session ). then ( ids => ids . map ( id => ({ project_id : id , title : id }))) : request < Project [] >( '/v1/admin/projects' , session ),
request < { principal_id : string } > ( '/v1/me' , session ),
]);
setCredentials ( keys ); setEvents ( activity ); setProjects ( availableProjects ); setOffset ( page );
setCurrentPrincipal ( identity . principal_id ?? '' );
if ( ! personal ) setProjectRecords ( availableProjects as Project []);
}, [ personal , offset ]);
const refreshAdminData = useCallback (() => load ( csrf ), [ load , csrf ]);
const loadAdminPage = useCallback (( page : number ) => load ( csrf , page ), [ load , csrf ]);
async function connect ( session : { csrf_token : string }, isPasswordAccount : boolean , restored = false ) {
setBusy ( true ); setError ( '' );
try {
await load ( session . csrf_token , 0 );
setActiveTab ( 'projects' );
setCsrf ( session . csrf_token ); setPasswordAccount ( isPasswordAccount );
setMessage ( personal ? 'Signed in. Manage the keys for your agents below.' : 'Connected. Credentials and recent activity are up to date.' );
} catch ( failure ) {
if ( ! restored ) await request ( '/v1/session' , session . csrf_token , { method : 'DELETE' }). catch (() => undefined );
throw failure ;
} finally { setBusy ( false ); }
}
async function perform ( action : () => Promise < void >) {
setBusy ( true ); setError ( '' );
try { await action (); }
catch ( failure ) {
if ( failure instanceof AdminRequestError && failure . status === 401 ) {
setCsrf ( '' ); setCredentials ([]); setEvents ([]); setProjects ([]); setIssued ( null );
setMessage ( 'Your session ended. Connect again to continue.' );
}
setError ( failure instanceof Error ? failure . message : 'Request failed.' );
}
finally { setBusy ( false ); }
}
const userProjectOptions = useMemo (() => projects . filter ( project =>
! projectRecords . some ( record => record . project_id === project . project_id && record . status === 'archived' )
), [ projects , projectRecords ]);
const filteredEvents = useMemo (() => events . filter ( event =>
( ! logAccount || event . actor === logAccount ) &&
( ! logType || event . action === logType ) &&
( ! logFrom || event . occurred_at . slice ( 0 , 10 ) >= logFrom ) &&
( ! logTo || event . occurred_at . slice ( 0 , 10 ) <= logTo )
), [ events , logAccount , logType , logFrom , logTo ]);
return < div className = "app-shell" >
< a className = "skip-link" href = "#main-content" > Skip to administration </ a >
< aside className = "rail" aria-label = "Primary navigation" >
< a className = "brand" href = "/" >< span className = "brand__mark" aria-hidden = "true" > G </ span >< span > GEWU < span className = "brand__sub" > RESEARCH LIBRARY </ span ></ span ></ a >
< nav className = "rail-nav" aria-label = "Library sections" >
< a className = "rail-nav__item" href = "/" > ⌕ Search </ a >
< a className = { `rail-nav__item ${ personal ? '' : 'rail-nav__item--active' } ` } href = "/admin" aria-current = { personal ? undefined : 'page' }> Administration </ a >
< a className = { `rail-nav__item ${ personal ? 'rail-nav__item--active' : '' } ` } href = "/agents" aria-current = { personal ? 'page' : undefined }> Agent credentials </ a >
</ nav >
< div className = "rail__footer" >< p className = "eyebrow" > SERVICE ACCESS </ p >< p > Credentials & activity </ p >< p className = "scope-note" > Access management for your GEWU service .</ p ></ div >
</ aside >
< main id = "main-content" className = "workspace" >
< header className = "topbar" >< div >< p className = "eyebrow" > SERVICE / ADMINISTRATION </ p >< h1 >{ personal ? 'Connect your agents.' : 'Manage access to your library.' }</ h1 ></ div >
{ csrf && < button className = "quiet-button" disabled = { busy } onClick = {() => void perform ( async () => {
await request ( '/v1/session' , csrf , { method : 'DELETE' });
setCsrf ( '' ); setCredentials ([]); setEvents ([]); setProjects ([]); setIssued ( null );
setMessage ( 'Disconnected.' );
})}> Disconnect </ button >}
</ header >
< p role = "status" >{ busy ? 'Working…' : message }</ p >
{ error && < p className = "admin-error" role = "alert" >{ error }</ p >}
{ ! csrf ? < section className = "connect-card" aria-labelledby = "admin-connect-title" >
< div >< p className = "eyebrow" >{ personal ? 'YOUR ACCOUNT' : 'ADMINISTRATOR ACCESS' }</ p >< h2 id = "admin-connect-title" > Sign in to your account </ h2 >< p >{ personal ? 'Manage separate keys for each agent you use. Your administrator assigns your project access.' : 'Sign in with your username and password. For initial setup, use your existing administrator credential to create the first account.' }</ p ></ div >
< AccountLogin onLogin = { connect } busy = { busy } credentialLabel = "GEWU administrator credential" onError = { setError } />
</ section > : <>
{ ! personal && < div className = "admin-tabs" role = "tablist" aria-label = "Administration sections" >
{ adminTabs . map (( tab , index ) => < button key = { tab . id } id = { `admin-tab- ${ tab . id } ` } type = "button" role = "tab"
aria-selected = { activeTab === tab . id } aria-controls = { `admin-panel- ${ tab . id } ` } tabIndex = { activeTab === tab . id ? 0 : - 1 }
onClick = {() => setActiveTab ( tab . id )} onKeyDown = { event => {
let next = index ;
if ( event . key === 'ArrowRight' ) next = ( index + 1 ) % adminTabs . length ;
else if ( event . key === 'ArrowLeft' ) next = ( index - 1 + adminTabs . length ) % adminTabs . length ;
else if ( event . key === 'Home' ) next = 0 ;
else if ( event . key === 'End' ) next = adminTabs . length - 1 ;
else return ;
event . preventDefault ();
setActiveTab ( adminTabs [ next ]. id );
document . getElementById ( `admin-tab- ${ adminTabs [ next ]. id } ` ) ? . focus ();
}}>{ tab . label }</ button >)}
</ div >}
{ ! personal && < div id = "admin-panel-projects" role = "tabpanel" aria-labelledby = "admin-tab-projects" hidden = { activeTab !== 'projects' } tabIndex = { 0 }>
< ProjectsSection csrf = { csrf } projects = { projectRecords } currentOwner = { currentPrincipal } onCreated = { refreshAdminData } onError = { setError } onMessage = { setMessage } />
</ div >}
2026-10-14 20:35:48 +08:00
{ ! personal && < div id = "admin-panel-ingest-review" role = "tabpanel" aria-labelledby = "admin-tab-ingest-review" hidden = { activeTab !== 'ingest-review' } tabIndex = { 0 }>
< IngestReviewSection csrf = { csrf } projects = { projectRecords } onError = { setError } onMessage = { setMessage } />
</ div >}
2026-10-14 19:21:35 +08:00
{ ! personal && < div id = "admin-panel-users" role = "tabpanel" aria-labelledby = "admin-tab-users" hidden = { activeTab !== 'users' } tabIndex = { 0 }>
< AccountsSection csrf = { csrf } projects = { userProjectOptions } onMessage = { setMessage } onError = { setError } />
</ div >}
< div id = { personal ? undefined : 'admin-panel-credentials' } role = { personal ? undefined : 'tabpanel' } aria-labelledby = { personal ? undefined : 'admin-tab-credentials' } hidden = { ! personal && activeTab !== 'credentials' } tabIndex = { personal ? undefined : 0 }>
{ issued && < section className = "admin-card admin-secret" aria-labelledby = "secret-title" >
< h2 id = "secret-title" > Save your new credential </ h2 >< p > The secret for < strong >{ issued . credential_id }</ strong > is shown once . Store it securely before dismissing it .</ p >
< label htmlFor = "issued-token" > New bearer credential </ label >< input id = "issued-token" readOnly value = { issued . token } autoComplete = "off" spellCheck = { false } />
< button className = "quiet-button" onClick = {() => { setIssued ( null ); setMessage ( 'Secret dismissed.' ); }}> I have saved it — dismiss </ button >
</ section >}
< CredentialsSection csrf = { csrf } personal = { personal } credentials = { credentials } projects = { projects }
offset = { offset } onPageChange = { loadAdminPage } onRefresh = { refreshAdminData } onIssued = { setIssued } onError = { setError } onMessage = { setMessage } />
</ div >
{ ! personal && < div id = "admin-panel-logs" role = "tabpanel" aria-labelledby = "admin-tab-logs" hidden = { activeTab !== 'logs' } tabIndex = { 0 }>
< section className = "admin-card" aria-labelledby = "activity-title" >< div className = "admin-heading" >< div >< h2 id = "activity-title" > Logs </ h2 >< p className = "muted-copy" > Filter the latest 50 account and credential events .</ p ></ div >< button className = "quiet-button" disabled = { busy } onClick = {() => void perform ( async () => { await load ( csrf ); setMessage ( 'Recent activity refreshed.' ); })}> Refresh </ button ></ div >
< div className = "admin-filter-bar" aria-label = "Filter logs" >
< label > Account < select value = { logAccount } onChange = { event => setLogAccount ( event . target . value )}>< option value = "" > All accounts </ option >{ Array . from ( new Set ( events . map ( item => item . actor ))). sort (). map ( actor => < option key = { actor } value = { actor }>{ actor }</ option >)}</ select ></ label >
< label > Type < select value = { logType } onChange = { event => setLogType ( event . target . value )}>< option value = "" > All types </ option >{ Array . from ( new Set ( events . map ( item => item . action ))). sort (). map ( action => < option key = { action } value = { action }>{ action . replaceAll ( '.' , ' ' )}</ option >)}</ select ></ label >
< label > From < input type = "date" value = { logFrom } onChange = { event => setLogFrom ( event . target . value )} /></ label >
< label > To < input type = "date" value = { logTo } onChange = { event => setLogTo ( event . target . value )} /></ label >
< button className = "text-button" type = "button" onClick = {() => { setLogAccount ( '' ); setLogType ( '' ); setLogFrom ( '' ); setLogTo ( '' ); }}> Clear filters </ button >
</ div >
{ filteredEvents . length ? < ol className = "admin-activity" >{ filteredEvents . map ( event => < li key = { event . event_id }>< strong >{ event . action . replaceAll ( '.' , ' ' )} · { event . credential_id }</ strong >< span >{ event . actor } · < time dateTime = { event . occurred_at }>{ new Date ( event . occurred_at ). toLocaleString ()}</ time ></ span ></ li >)}</ ol > : < p >{ events . length ? 'No events match these filters.' : 'No account or credential activity recorded yet.' }</ p >}
</ section >
</ div >}
< div id = { personal ? undefined : 'admin-panel-settings' } role = { personal ? undefined : 'tabpanel' } aria-labelledby = { personal ? undefined : 'admin-tab-settings' } hidden = { ! personal && activeTab !== 'settings' } tabIndex = { personal ? undefined : 0 }>
{ ! personal && < section className = "admin-card" aria-labelledby = "settings-title" >
< h2 id = "settings-title" > Admin Settings </ h2 >
< p > Signed in as < strong >{ currentPrincipal || 'administrator' }</ strong >.</ p >
< p className = "muted-copy" >{ passwordAccount ? 'Manage the password for your administrator account below.' : 'You are connected with an administrator credential. To manage a password, create an administrator account in Users, then sign in with its username and password.' }</ p >
</ section >}
{ passwordAccount && < PasswordSection csrf = { csrf } onError = { setError } onChanged = {() => {
setCsrf ( '' ); setCredentials ([]); setEvents ([]); setIssued ( null ); setProjects ([]);
setMessage ( 'Password changed. Sign in with your new password.' );
}} />}
</ div >
</>}
</ main >
</ div >;
}
export default Admin ;