mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
* feat(admin): add a Billing tab for subscriptions and sales
The admin can now follow subscriptions and sales without opening Stripe:
- Summary tiles: active subscriptions (monthly/annual), MRR with annual
plans counted as 1/12, sales and revenue for 7d/30d/90d/12m/all time,
all-time sales, subscriptions cancelling at period end, and refunds
and disputes.
- A stacked chart of sales per day, week or month by plan family.
- A by-plan table: active grants, period and all-time sales and revenue.
- A paginated list of grants with plan, Stripe status, active/expired/
revoked and user filters, linked to the admin user, repository or
conference.
- One row of webhook health: pending, failed, oldest pending event, last
error and unreviewed payment reversals.
A sale is one checkout or subscription, so a paper pack's three grants
count once. Revenue is each sale's Stripe list price, resolved once per
price and cached in memory. Currencies are never added together. Without
Stripe the page shows counts and marks revenue as unavailable.
GET /api/admin/billing/summary and /grants sit behind the admin check.
Grants get a { purchasedAt, _id } index for the list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: preserve payment history in admin billing totals
* fix: scope billing history and preserve original sale prices
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
12 KiB
HTML
135 lines
12 KiB
HTML
<div class="container paper-page admin-page">
|
||
<div class="paper-crumbs">Admin / <span class="here">Users</span></div>
|
||
<h1 class="paper-page-title">Users</h1>
|
||
|
||
<nav class="admin-nav">
|
||
<a href="/admin/"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-chart"></use></svg> Overview</a>
|
||
<a href="/admin/repositories"><svg class="ui-icon" aria-hidden="true"><use href="#project-type-repo"></use></svg> Repositories</a>
|
||
<a href="/admin/users" class="active"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-users"></use></svg> Users</a>
|
||
<a href="/admin/conferences"><svg class="ui-icon" aria-hidden="true"><use href="#project-icon-conference"></use></svg> Conferences</a>
|
||
<a href="/admin/billing"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-card"></use></svg> Billing</a>
|
||
<a href="/admin/queues"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-list"></use></svg> Queues</a>
|
||
<a href="/admin/errors"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-bug"></use></svg> Errors</a>
|
||
</nav>
|
||
|
||
<div class="seg-bar">
|
||
<div class="seg-filter" role="radiogroup" aria-label="Filter by status">
|
||
<button type="button" role="radio" class="seg-chip" :class="{ active: !query?.status }" :aria-checked="!query?.status" @click="query.status = ''; query.page = 1">All <span class="count">{{ fmt?.number(statusCountFor('active') + statusCountFor('banned') + statusCountFor('removed')) }}</span></button>
|
||
<button type="button" role="radio" class="seg-chip" :class="{ active: query?.status === 'active', 'is-empty': !statusCountFor('active') }" :aria-checked="query?.status === 'active'" @click="query.status = 'active'; query.page = 1"><span class="seg-dot ok" aria-hidden="true"></span>Active <span class="count">{{ fmt?.number(statusCountFor('active')) }}</span></button>
|
||
<button type="button" role="radio" class="seg-chip" :class="{ active: query?.status === 'banned', 'is-empty': !statusCountFor('banned') }" :aria-checked="query?.status === 'banned'" @click="query.status = 'banned'; query.page = 1"><span class="seg-dot bad" aria-hidden="true"></span>Banned <span class="count">{{ fmt?.number(statusCountFor('banned')) }}</span></button>
|
||
<button type="button" role="radio" class="seg-chip" :class="{ active: query?.status === 'removed', 'is-empty': !statusCountFor('removed') }" :aria-checked="query?.status === 'removed'" @click="query.status = 'removed'; query.page = 1"><span class="seg-dot off" aria-hidden="true"></span>Removed <span class="count">{{ fmt?.number(statusCountFor('removed')) }}</span></button>
|
||
</div>
|
||
<p class="seg-countline" role="status"><strong>{{ total >= 0 ? fmt.number(total) : '…' }}</strong> shown</p>
|
||
</div>
|
||
|
||
<div class="alert alert-danger" style="margin: 8px 0;" v-if="fetchError">
|
||
<svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-warning"></use></svg> {{ fetchError }}
|
||
</div>
|
||
|
||
<form class="w-100 admin-filter-toolbar" aria-label="Users" accept-charset="UTF-8" v-form="viewState">
|
||
<div class="admin-filter-row">
|
||
<div class="search-wrap">
|
||
<input type="search" class="form-control" aria-label="Search users" placeholder="Search username or email…" autocomplete="off" v-field="{ state: viewState, set: value => { query.search = value }, value: query?.search, form: null, options: {} }">
|
||
<span class="admin-search-hint" v-if="!query?.search">/</span>
|
||
</div>
|
||
<span class="admin-filter-inline"><label>Role</label>
|
||
<select class="form-control form-control-sm" v-field="{ state: viewState, set: value => { query.role = value }, value: query?.role, form: null, options: {} }"><option value="">Any</option><option value="admin">Admin</option></select>
|
||
</span>
|
||
<span class="admin-filter-spacer"></span>
|
||
<button class="btn btn-sm" type="button" @click="exportCsv()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-export"></use></svg> Export</button>
|
||
<span class="admin-filter-inline" aria-label="Pagination" v-if="totalPage > 1">
|
||
<button class="btn btn-sm btn-icon" type="button" @click="query.page = Math.max(1, query.page - 1)" :disabled="query?.page <= 1" aria-label="Previous page"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-chevron-left"></use></svg></button>
|
||
<span style="font-family: var(--font-mono); font-size: 12px; color: var(--ink-muted);">{{ query?.page }}/{{ totalPage || 1 }}</span>
|
||
<button class="btn btn-sm btn-icon" type="button" @click="query.page = Math.min(totalPage, query.page + 1)" :disabled="query?.page >= totalPage" aria-label="Next page"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-chevron-right"></use></svg></button>
|
||
</span>
|
||
</div>
|
||
|
||
<div class="admin-filter-row" v-if="chips?.length">
|
||
<div class="admin-active-chips">
|
||
<span class="admin-active-chip" v-for="(chip, index) in chips" :key="chip?.key">
|
||
<span class="key">{{ chip?.label }}</span>
|
||
<span>{{ chip?.value }}</span>
|
||
<button type="button" @click="clearFilter(chip.key)" aria-label="Clear filter"><svg class="ui-icon" aria-hidden="true"><use href="#project-icon-close"></use></svg></button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
|
||
<div class="bulk-bar" v-if="selectedCount() > 0">
|
||
<span><strong>{{ selectedCount() }}</strong> selected</span>
|
||
<button class="btn btn-sm text-danger" type="button" @click="bulkBan()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-ban"></use></svg> Ban</button>
|
||
<button class="btn btn-sm" type="button" @click="selected = {}; allSelected = false">Clear</button>
|
||
</div>
|
||
|
||
<div class="paper-table w-100" role="table" aria-label="Users" style="--cols: 28px minmax(280px, 2.4fr) 100px 140px 140px 52px;">
|
||
<div class="paper-table-head admin-users-row" role="row">
|
||
<div role="columnheader" style="width: 28px;">
|
||
<input type="checkbox" aria-label="Select all on page" @click="selectAllOnPage()" :checked="allSelected">
|
||
</div>
|
||
<div role="columnheader"><button type="button" class="sortable" :class="{active: query?.sort == 'username'}" @click="sortBy('username')">User <svg class="ui-icon sort-direction" aria-hidden="true" v-if="sortIcon('username')" :class="{ascending: sortIcon('username') === 'fa-arrow-up'}"><use href="#project-icon-arrow"></use></svg></button></div>
|
||
<div role="columnheader" class="num" title="Repositories discovered on the user's GitHub account">GitHub repos</div>
|
||
<div role="columnheader"><button type="button" class="sortable" :class="{active: query?.sort == 'status'}" @click="sortBy('status')">Status <svg class="ui-icon sort-direction" aria-hidden="true" v-if="sortIcon('status')" :class="{ascending: sortIcon('status') === 'fa-arrow-up'}"><use href="#project-icon-arrow"></use></svg></button></div>
|
||
<div role="columnheader">Role</div>
|
||
<div role="columnheader" aria-label="Actions"></div>
|
||
</div>
|
||
<div class="paper-table-row admin-users-row" role="row" v-for="(u, index) in filteredUsers" :class="{'row-selected': selected[u?.username]}">
|
||
<div role="cell" style="width: 28px;">
|
||
<input type="checkbox" aria-label="Select user" v-field="{ state: viewState, set: value => { selected[u.username] = value }, value: selected[u?.username], form: null, options: {} }">
|
||
</div>
|
||
<div class="cell-anon" role="cell">
|
||
<img width="28" height="28" class="rounded-circle" style="flex-shrink:0;" v-if="u?.photo" :src="safeUrl((u?.photo))">
|
||
<span class="avatar-fallback" aria-hidden="true" v-else>{{ (u?.username || '?').charAt(0).toUpperCase() }}</span>
|
||
<div class="anon-text">
|
||
<span class="user-name-line"><a class="repo-name" v-text="u?.username" :href="safeUrl("/admin/users/" + (u?.username))"></a><a class="user-gh-link" target="_blank" rel="noopener" :href="safeUrl("https://github.com/" + (u?.username))" :title="(u?.username) + ' on GitHub'" :aria-label="(u?.username) + ' on GitHub'"><i class="fab fa-github" aria-hidden="true"></i></a></span>
|
||
<div class="anon-sub meta-dots">
|
||
<span v-if="u?.emails?.[0]?.email" class="meta-email meta-tail" :title="u?.emails?.[0]?.email">{{ u?.emails?.[0]?.email }}</span><span v-if="u?.dateOfEntry">Joined {{ fmt?.humanTime(u?.dateOfEntry) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="cell-views num" role="cell"><svg class="ui-icon mobile-meta-icon" aria-hidden="true"><use href="#project-type-repo"></use></svg>
|
||
<a :title="u?.repoCount ? 'Show this user’s repositories' : 'Repositories not synced yet'" v-text="u?.repoCount ? fmt?.number(u.repoCount) : 'Not synced'" :class="{ 'is-unsynced': !u?.repoCount }" :href="safeUrl("/admin/repositories?owner=" + (u?.username))"></a>
|
||
</div>
|
||
<div class="cell-status" role="cell">
|
||
<span class="status-dot" :class="'st-' + (u?.status || 'unknown')"></span>
|
||
<span v-text="fmt?.title(u?.status)"></span>
|
||
</div>
|
||
<div class="cell-status" role="cell">
|
||
<span class="role-pill" v-if="u?.isAdmin">Admin</span>
|
||
<span class="empty-dash" v-if="!u?.isAdmin">—</span>
|
||
</div>
|
||
<div class="cell-actions" role="cell">
|
||
<div class="dropdown">
|
||
<button class="btn btn-icon-dots" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="Actions">
|
||
<svg class="ui-icon" aria-hidden="true"><use href="#project-icon-more"></use></svg>
|
||
</button>
|
||
<div class="dropdown-menu dropdown-menu-right">
|
||
<a class="dropdown-item" :href="safeUrl("/admin/users/" + (u?.username))"><svg class="ui-icon" aria-hidden="true"><use href="#project-icon-eye"></use></svg> View details</a>
|
||
<a class="dropdown-item" :href="safeUrl("/admin/repositories?owner=" + (u?.username))"><svg class="ui-icon" aria-hidden="true"><use href="#project-type-repo"></use></svg> View repositories</a>
|
||
<div class="dropdown-divider"></div>
|
||
<a class="dropdown-item text-danger" href="#" v-show="u?.status == 'active'" @click.prevent="banUser(u)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-ban"></use></svg> Ban</a>
|
||
<a class="dropdown-item" href="#" v-show="u?.status == 'removed' || u?.status == 'banned'" @click.prevent="activateUser(u)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-check-circle"></use></svg> Activate</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="paper-table-empty" v-if="filteredUsers?.length == 0">
|
||
<svg class="ui-icon" aria-hidden="true"><use href="#project-icon-inbox"></use></svg>
|
||
<span>No users match the current filters.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-toolbar" style="justify-content: space-between; border-bottom: none;">
|
||
<span aria-hidden="true"></span>
|
||
<div class="pagination-compact" v-if="totalPage > 1">
|
||
<button class="btn btn-sm" @click="query.page = Math.max(1, query.page - 1)" :disabled="query?.page <= 1"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-chevron-left"></use></svg> Previous</button>
|
||
<input type="number" class="form-control form-control-sm" min="1" style="width: 56px;" v-field="{ state: viewState, set: value => { query.page = value }, value: query?.page, form: null, options: {} }" :max="(totalPage)">
|
||
<span>of {{ totalPage }}</span>
|
||
<button class="btn btn-sm" @click="query.page = Math.min(totalPage, query.page + 1)" :disabled="query?.page >= totalPage">Next <svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-chevron-right"></use></svg></button>
|
||
</div>
|
||
<span class="admin-filter-inline">
|
||
<label>Per page</label>
|
||
<select class="form-control form-control-sm" v-field="{ state: viewState, set: value => { query.limit = value }, value: query?.limit, form: null, options: {} }"><option value="10">10</option><option value="25">25</option><option value="50">50</option><option value="100">100</option></select>
|
||
</span>
|
||
</div>
|
||
</div>
|