Build 2602261605: invite trace and cross-system user lifecycle

This commit is contained in:
2026-02-26 16:06:09 +13:00
parent bd3c0bdade
commit 1b1a3e233b
13 changed files with 976 additions and 16 deletions

View File

@@ -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>
)