Build 2602261605: invite trace and cross-system user lifecycle
This commit is contained in:
@@ -9,8 +9,12 @@ type AdminUserLite = {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
auth_provider?: string | null
|
||||
profile_id?: number | null
|
||||
expires_at?: string | null
|
||||
created_at?: string | null
|
||||
invited_by_code?: string | null
|
||||
invited_at?: string | null
|
||||
}
|
||||
|
||||
type Profile = {
|
||||
@@ -41,6 +45,7 @@ type Invite = {
|
||||
is_expired?: boolean
|
||||
is_usable?: boolean
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
}
|
||||
|
||||
type InviteForm = {
|
||||
@@ -63,7 +68,7 @@ type ProfileForm = {
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
type InviteManagementTab = 'bulk' | 'profiles' | 'invites'
|
||||
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace'
|
||||
|
||||
const defaultInviteForm = (): InviteForm => ({
|
||||
code: '',
|
||||
@@ -116,6 +121,7 @@ export default function AdminInviteManagementPage() {
|
||||
const [bulkProfileId, setBulkProfileId] = useState('')
|
||||
const [bulkExpiryDays, setBulkExpiryDays] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
|
||||
const [traceFilter, setTraceFilter] = useState('')
|
||||
|
||||
const signupBaseUrl = useMemo(() => {
|
||||
if (typeof window === 'undefined') return '/signup'
|
||||
@@ -468,6 +474,133 @@ export default function AdminInviteManagementPage() {
|
||||
const disabledInvites = invites.filter((invite) => invite.enabled === false).length
|
||||
const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length
|
||||
|
||||
const inviteTraceRows = useMemo(() => {
|
||||
const inviteByCode = new Map<string, Invite>()
|
||||
invites.forEach((invite) => {
|
||||
const code = String(invite.code || '').trim()
|
||||
if (code) inviteByCode.set(code.toLowerCase(), invite)
|
||||
})
|
||||
|
||||
const userByName = new Map<string, AdminUserLite>()
|
||||
users.forEach((user) => {
|
||||
const username = String(user.username || '').trim()
|
||||
if (username) userByName.set(username.toLowerCase(), user)
|
||||
})
|
||||
|
||||
const childrenByInviter = new Map<string, AdminUserLite[]>()
|
||||
const inviterMetaByUser = new Map<
|
||||
string,
|
||||
{ inviterUsername: string | null; inviteCode: string | null; inviteLabel: string | null }
|
||||
>()
|
||||
|
||||
users.forEach((user) => {
|
||||
const username = String(user.username || '').trim()
|
||||
if (!username) return
|
||||
const inviteCodeRaw = String(user.invited_by_code || '').trim()
|
||||
let inviterUsername: string | null = null
|
||||
let inviteLabel: string | null = null
|
||||
if (inviteCodeRaw) {
|
||||
const invite = inviteByCode.get(inviteCodeRaw.toLowerCase())
|
||||
inviteLabel = (invite?.label as string | undefined) || null
|
||||
const createdBy = String(invite?.created_by || '').trim()
|
||||
if (createdBy) inviterUsername = createdBy
|
||||
}
|
||||
inviterMetaByUser.set(username.toLowerCase(), {
|
||||
inviterUsername,
|
||||
inviteCode: inviteCodeRaw || null,
|
||||
inviteLabel,
|
||||
})
|
||||
const key = (inviterUsername || '__root__').toLowerCase()
|
||||
const bucket = childrenByInviter.get(key) ?? []
|
||||
bucket.push(user)
|
||||
childrenByInviter.set(key, bucket)
|
||||
})
|
||||
|
||||
childrenByInviter.forEach((bucket) =>
|
||||
bucket.sort((a, b) => String(a.username || '').localeCompare(String(b.username || ''), undefined, { sensitivity: 'base' }))
|
||||
)
|
||||
|
||||
const rows: Array<{
|
||||
username: string
|
||||
role: string
|
||||
authProvider: string
|
||||
level: number
|
||||
inviterUsername: string | null
|
||||
inviteCode: string | null
|
||||
inviteLabel: string | null
|
||||
createdAt: string | null
|
||||
childCount: number
|
||||
isCycle?: boolean
|
||||
}> = []
|
||||
|
||||
const visited = new Set<string>()
|
||||
const walk = (user: AdminUserLite, level: number, path: Set<string>) => {
|
||||
const username = String(user.username || '').trim()
|
||||
const userKey = username.toLowerCase()
|
||||
if (!username) return
|
||||
const meta = inviterMetaByUser.get(userKey) ?? {
|
||||
inviterUsername: null,
|
||||
inviteCode: null,
|
||||
inviteLabel: null,
|
||||
}
|
||||
const childCount = (childrenByInviter.get(userKey) ?? []).length
|
||||
if (path.has(userKey)) {
|
||||
rows.push({
|
||||
username,
|
||||
role: String(user.role || 'user'),
|
||||
authProvider: String(user.auth_provider || 'local'),
|
||||
level,
|
||||
inviterUsername: meta.inviterUsername,
|
||||
inviteCode: meta.inviteCode,
|
||||
inviteLabel: meta.inviteLabel,
|
||||
createdAt: (user.created_at as string | null) ?? null,
|
||||
childCount,
|
||||
isCycle: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
rows.push({
|
||||
username,
|
||||
role: String(user.role || 'user'),
|
||||
authProvider: String(user.auth_provider || 'local'),
|
||||
level,
|
||||
inviterUsername: meta.inviterUsername,
|
||||
inviteCode: meta.inviteCode,
|
||||
inviteLabel: meta.inviteLabel,
|
||||
createdAt: (user.created_at as string | null) ?? null,
|
||||
childCount,
|
||||
})
|
||||
visited.add(userKey)
|
||||
const nextPath = new Set(path)
|
||||
nextPath.add(userKey)
|
||||
;(childrenByInviter.get(userKey) ?? []).forEach((child) => walk(child, level + 1, nextPath))
|
||||
}
|
||||
|
||||
;(childrenByInviter.get('__root__') ?? []).forEach((rootUser) => walk(rootUser, 0, new Set()))
|
||||
users.forEach((user) => {
|
||||
const key = String(user.username || '').toLowerCase()
|
||||
if (key && !visited.has(key)) {
|
||||
walk(user, 0, new Set())
|
||||
}
|
||||
})
|
||||
|
||||
const filter = traceFilter.trim().toLowerCase()
|
||||
if (!filter) return rows
|
||||
return rows.filter((row) =>
|
||||
[
|
||||
row.username,
|
||||
row.inviterUsername || '',
|
||||
row.inviteCode || '',
|
||||
row.inviteLabel || '',
|
||||
row.role || '',
|
||||
row.authProvider || '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(filter)
|
||||
)
|
||||
}, [invites, traceFilter, users])
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
title="Invite management"
|
||||
@@ -547,6 +680,15 @@ export default function AdminInviteManagementPage() {
|
||||
>
|
||||
Invites
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'trace'}
|
||||
className={activeTab === 'trace' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('trace')}
|
||||
>
|
||||
Trace map
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-inline-actions invite-admin-tab-actions">
|
||||
<button type="button" className="ghost-button" onClick={loadData} disabled={loading}>
|
||||
@@ -1064,6 +1206,78 @@ export default function AdminInviteManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'trace' && (
|
||||
<div className="invite-admin-stack">
|
||||
<div className="admin-panel invite-admin-list-panel">
|
||||
<div className="user-directory-panel-header">
|
||||
<div>
|
||||
<h2>Invite trace map</h2>
|
||||
<p className="lede">
|
||||
Visual lineage of who invited who, including the invite code used for each sign-up.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-trace-toolbar">
|
||||
<label className="invite-trace-filter">
|
||||
<span>Find user / inviter / code</span>
|
||||
<input
|
||||
type="search"
|
||||
value={traceFilter}
|
||||
onChange={(e) => setTraceFilter(e.target.value)}
|
||||
placeholder="Search by username, inviter, or invite code"
|
||||
/>
|
||||
</label>
|
||||
<div className="invite-trace-summary">
|
||||
<span>{inviteTraceRows.length} rows shown</span>
|
||||
<span>{users.length} users loaded</span>
|
||||
<span>{invites.length} invites loaded</span>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="status-banner">Loading trace map…</div>
|
||||
) : inviteTraceRows.length === 0 ? (
|
||||
<div className="status-banner">No trace matches found.</div>
|
||||
) : (
|
||||
<div className="invite-trace-map">
|
||||
{inviteTraceRows.map((row) => (
|
||||
<div key={`${row.username}-${row.level}-${row.inviteCode || 'direct'}`} className="invite-trace-row">
|
||||
<div className="invite-trace-row-main" style={{ paddingLeft: `${row.level * 18}px` }}>
|
||||
<span className="invite-trace-branch" aria-hidden="true" />
|
||||
<span className="invite-trace-user">{row.username}</span>
|
||||
<span className={`small-pill ${row.role === 'admin' ? '' : 'is-muted'}`}>{row.role}</span>
|
||||
<span className="small-pill">{row.authProvider}</span>
|
||||
{row.isCycle && <span className="small-pill is-muted">cycle</span>}
|
||||
</div>
|
||||
<div className="invite-trace-row-meta">
|
||||
<span className="invite-trace-meta-item">
|
||||
<span className="label">Invited by</span>
|
||||
<strong>{row.inviterUsername || 'Root/direct'}</strong>
|
||||
</span>
|
||||
<span className="invite-trace-meta-item">
|
||||
<span className="label">Via code</span>
|
||||
<strong>{row.inviteCode || 'None'}</strong>
|
||||
</span>
|
||||
<span className="invite-trace-meta-item">
|
||||
<span className="label">Invite label</span>
|
||||
<strong>{row.inviteLabel || 'None'}</strong>
|
||||
</span>
|
||||
<span className="invite-trace-meta-item">
|
||||
<span className="label">Children</span>
|
||||
<strong>{row.childCount}</strong>
|
||||
</span>
|
||||
<span className="invite-trace-meta-item">
|
||||
<span className="label">Created</span>
|
||||
<strong>{formatDate(row.createdAt)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user