mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
Backend, scripts, docs and their tests take main's newer code where both sides changed the same lines (the shared blob cache, GitHubStream and markdown fixes landed on main after the Go migration branch was cut). The Dockerfile keeps the Go build from the migration, plus main's owner-alert-protocol label; .gitignore keeps both sides' entries. Frontend keeps the redesign and folds in main's features: Stripe billing in the New anonymization submit bar, GitHub access repair, redaction syntax help, the preview expand toggle, configurable redaction mask, folder counts, reviewer-route header rules, link expiry (now in the viewer's snapshot details) and the explorer error card. main's narrow-only wrap logic is dropped because the redesign wraps on desktop and scrolls sideways on phones. Tests: 1534 passing with SESSION_SECRET set (as in CI). Three main tests were updated for the agreed viewer design (no site header in the viewer, no repository-wide file count). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 lines
19 KiB
HTML
223 lines
19 KiB
HTML
<div class="container paper-page admin-page">
|
|
<div class="paper-crumbs"><a href="/admin/">Admin</a> / <a href="/admin/users">Users</a> / <span class="here">{{ userInfo?.username || 'Profile' }}</span></div>
|
|
<h1 class="paper-page-title">{{ userInfo?.username || 'User' }}</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/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="user-detail-card" v-if="userInfo">
|
|
<div class="user-header">
|
|
<img width="56" height="56" v-if="userInfo?.photo" :src="safeUrl((userInfo?.photo))">
|
|
<div>
|
|
<h2>
|
|
{{ userInfo?.username }}
|
|
<span class="status-dot-wrap">
|
|
<span class="status-dot" :class="{'status-ready': userInfo?.status == 'active', 'status-removed': userInfo?.status != 'active'}"></span>
|
|
<span v-text="fmt?.title(userInfo?.status)"></span>
|
|
</span>
|
|
<span class="type-badge type-repo" v-if="userInfo?.isAdmin">Admin</span>
|
|
</h2>
|
|
<div class="user-actions" style="margin-top: 4px;">
|
|
<button class="btn btn-sm btn-outline-danger" v-if="userInfo?.status !== 'banned' && userInfo?.username !== user?.username" @click="banUser()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-ban"></use></svg> Ban</button>
|
|
<button class="btn btn-sm" v-if="userInfo?.status === 'banned' || userInfo?.status === 'removed'" @click="activateUser()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-check-circle"></use></svg> Activate</button>
|
|
<button class="btn btn-sm" v-if="!userInfo?.isAdmin" @click="promoteUser()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-user-shield"></use></svg> Promote to admin</button>
|
|
<button class="btn btn-sm btn-outline-ink" v-if="userInfo?.isAdmin && userInfo?.username !== user?.username" @click="demoteUser()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-user-minus"></use></svg> Remove admin</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="user-detail-grid">
|
|
<div class="detail-label">ID</div>
|
|
<div class="detail-value mono-id">{{ userInfo?._id }}</div>
|
|
|
|
<div class="detail-label">Email</div>
|
|
<div class="detail-value" :class="{ 'is-empty': !userInfo?.emails?.[0]?.email }">{{ userInfo?.emails?.[0]?.email || 'Not shared' }}</div>
|
|
|
|
<div class="detail-label">Access token</div>
|
|
<div class="detail-value" :class="{ 'is-empty': !userInfo?.accessTokens?.github }" style="font-family: var(--font-mono); font-size: 0.85rem;">{{ userInfo?.accessTokens?.github || 'Not stored' }}</div>
|
|
|
|
<div class="detail-label">GitHub</div>
|
|
<div class="detail-value">
|
|
<a target="_blank" :href="safeUrl("https://github.com/" + (userInfo?.username))">
|
|
<i class="fab fa-github"></i> {{ userInfo?.username }}
|
|
</a>
|
|
</div>
|
|
|
|
<div class="detail-label">Created</div>
|
|
<div class="detail-value">{{ fmt?.humanTime(userInfo?.dateOfEntry) }}</div>
|
|
|
|
<div class="detail-label">Last connection</div>
|
|
<div class="detail-value">
|
|
<span v-if="userInfo?.accessTokenDates?.github">{{ fmt?.humanTime(userInfo?.accessTokenDates?.github) }}</span>
|
|
<span class="text-muted" v-if="!userInfo?.accessTokenDates?.github">Not recorded (GitHub App sign-in)</span>
|
|
</div>
|
|
|
|
<div class="detail-label">GitHub repos</div>
|
|
<div class="detail-value">
|
|
{{ userInfo?.repositories?.length ? userInfo.repositories.length + " repositories" : "Not synced" }}
|
|
<button class="btn btn-sm ml-2" v-if="userInfo?.repositories?.length" @click="showRepos = !showRepos">
|
|
{{ showRepos ? 'Hide repos' : 'Show repos' }}
|
|
</button>
|
|
<button class="btn btn-sm ml-1" @click="getGitHubRepositories()">
|
|
<svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-refresh"></use></svg> Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top: 20px" v-if="showRepos">
|
|
<div class="paper-section-eyebrow">GitHub repositories</div>
|
|
<div class="paper-table w-100" style="margin-top: 10px;">
|
|
<div class="paper-table-row" style="grid-template-columns: 1fr 160px;" v-for="(repo, index) in userInfo?.repositories">
|
|
<div class="cell-anon" role="cell">
|
|
<svg class="ui-icon record-type-icon" role="img" aria-label="Repository"><use href="#project-type-repo"></use></svg>
|
|
<div class="anon-text">
|
|
<span class="repo-name" v-text="repo?.name"></span>
|
|
</div>
|
|
</div>
|
|
<div class="cell-expires" role="cell">
|
|
<svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-database"></use></svg> {{ fmt?.humanFileSize(repo?.size) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="admin-section-header" v-if="userInfo && userInfo?.isAdmin && user && user?.username == userInfo?.username">
|
|
<h2><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-key"></use></svg> API tokens</h2>
|
|
<span class="section-count">{{ tokens?.length }}</span>
|
|
</div>
|
|
|
|
<div class="user-detail-card" v-if="userInfo && userInfo?.isAdmin && user && user?.username == userInfo?.username">
|
|
<p class="paper-page-lede">Personal API tokens for this admin account. Send as <code>Authorization: Bearer <token></code> to authenticate without GitHub OAuth (useful for development).</p>
|
|
|
|
<form class="d-flex" style="gap: 8px; margin-bottom: 12px;" v-form="viewState" @submit.prevent="submitForm($event, () => { createToken() })">
|
|
<input type="text" class="form-control" placeholder="Token name (e.g. dev-laptop)" required v-field="{ state: viewState, set: value => { tokenForm.name = value }, value: tokenForm?.name, form: null, options: {} }">
|
|
<button type="submit" class="btn btn-ink"><svg class="ui-icon" aria-hidden="true"><use href="#project-icon-plus"></use></svg> Generate</button>
|
|
</form>
|
|
|
|
<div class="alert alert-warning" role="alert" v-if="tokenForm?.plaintext">
|
|
<strong>Copy this token now — it will not be shown again:</strong>
|
|
<pre style="white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; font-family: var(--font-mono); font-size: 0.85rem;">{{ tokenForm?.plaintext }}</pre>
|
|
<button class="btn btn-sm" @click="tokenForm.plaintext = null">Dismiss</button>
|
|
</div>
|
|
|
|
<div class="paper-table w-100" v-if="tokens?.length">
|
|
<div class="paper-table-head" role="row" style="grid-template-columns: 1fr 200px 200px 80px;">
|
|
<div role="columnheader">Name</div>
|
|
<div role="columnheader">Created</div>
|
|
<div role="columnheader">Last used</div>
|
|
<div role="columnheader" aria-label="Actions"></div>
|
|
</div>
|
|
<div class="paper-table-row" role="row" style="grid-template-columns: 1fr 200px 200px 80px;" v-for="(t, index) in tokens">
|
|
<div role="cell" v-text="t?.name"></div>
|
|
<div role="cell" v-text="fmt?.humanTime(t?.createdAt)"></div>
|
|
<div role="cell"><span v-if="t?.lastUsedAt">{{ fmt?.humanTime(t?.lastUsedAt) }}</span><span class="text-muted" v-if="!t?.lastUsedAt">never</span></div>
|
|
<div role="cell">
|
|
<button class="btn btn-sm text-danger btn-icon" title="Revoke" aria-label="Revoke token" @click="revokeToken(t)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-trash"></use></svg></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="paper-table-empty" v-if="!tokens?.length">
|
|
<svg class="ui-icon" aria-hidden="true"><use href="#project-icon-inbox"></use></svg>
|
|
<span>No tokens yet.</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="admin-section-header">
|
|
<h2><svg class="ui-icon" aria-hidden="true"><use href="#project-type-repo"></use></svg> Anonymized repositories</h2>
|
|
<span class="section-count">{{ repositories?.length }}</span>
|
|
</div>
|
|
|
|
<div class="admin-summary">
|
|
<span class="summary-total">{{ fmt?.number(repositories?.length) }}</span>
|
|
<button type="button" class="summary-pill ok" title="Toggle ready filter" :class="{active: filters?.status?.ready === false}" @click="filters.status.ready = !filters.status.ready" :aria-pressed="!!(filters?.status?.ready === false)">Ready <span class="count">{{ fmt?.number(statusCountFor('ready')) }}</span></button>
|
|
<button type="button" class="summary-pill warn" title="Toggle preparing filter" :class="{active: filters?.status?.preparing === false}" @click="filters.status.preparing = !filters.status.preparing" :aria-pressed="!!(filters?.status?.preparing === false)">Preparing <span class="count">{{ fmt?.number(statusCountFor('preparing')) }}</span></button>
|
|
<button type="button" class="summary-pill error" title="Toggle errored filter" :class="{active: filters?.status?.error === false}" @click="filters.status.error = !filters.status.error" :aria-pressed="!!(filters?.status?.error === false)">Errored <span class="count">{{ fmt?.number(statusCountFor('error')) }}</span></button>
|
|
<button type="button" class="summary-pill" title="Toggle expired filter" :class="{active: filters?.status?.expired === false}" @click="filters.status.expired = !filters.status.expired" :aria-pressed="!!(filters?.status?.expired === false)">Expired <span class="count">{{ fmt?.number(statusCountFor('expired')) }}</span></button>
|
|
<button type="button" class="summary-pill" title="Toggle removed filter" :class="{active: filters?.status?.removed === false}" @click="filters.status.removed = !filters.status.removed" :aria-pressed="!!(filters?.status?.removed === false)">Removed <span class="count">{{ fmt?.number(statusCountFor('removed')) }}</span></button>
|
|
</div>
|
|
|
|
<form class="w-100 admin-filter-toolbar" aria-label="Repositories" 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 repositories" placeholder="Search repoId, source repo, error message…" autocomplete="off" v-field="{ state: viewState, set: value => { search = value }, value: search, form: null, options: {} }">
|
|
</div>
|
|
<span class="admin-filter-spacer"></span>
|
|
<button class="btn btn-sm" type="button" title="Export current view to CSV" @click="exportCsv()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-export"></use></svg> Export</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div class="bulk-bar" v-if="selectedCount() > 0">
|
|
<span><strong>{{ selectedCount() }}</strong> selected</span>
|
|
<button class="btn btn-sm" type="button" @click="bulkRefresh()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-refresh"></use></svg> Force refresh</button>
|
|
<button class="btn btn-sm text-danger" type="button" @click="bulkRemoveCache()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-broom"></use></svg> Remove cache</button>
|
|
<button class="btn btn-sm" type="button" @click="clearSelection()">Clear</button>
|
|
</div>
|
|
|
|
<div class="paper-table paper-table-repos has-bulk w-100" role="table" aria-label="Repositories">
|
|
<div class="paper-table-head" 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 == 'source.repositoryName'}" @click="sortBy('source.repositoryName')">Repository <svg class="ui-icon sort-direction" aria-hidden="true" v-if="sortIcon('source.repositoryName')" :class="{ascending: sortIcon('source.repositoryName') === 'fa-arrow-up'}"><use href="#project-icon-arrow"></use></svg></button></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" class="num"><button type="button" class="sortable" :class="{active: query?.sort == 'pageView'}" @click="sortBy('pageView')">Views <svg class="ui-icon sort-direction" aria-hidden="true" v-if="sortIcon('pageView')" :class="{ascending: sortIcon('pageView') === 'fa-arrow-up'}"><use href="#project-icon-arrow"></use></svg></button></div>
|
|
<div role="columnheader"><button type="button" class="sortable" :class="{active: query?.sort == 'anonymizeDate'}" @click="sortBy('anonymizeDate')">Anonymized <svg class="ui-icon sort-direction" aria-hidden="true" v-if="sortIcon('anonymizeDate')" :class="{ascending: sortIcon('anonymizeDate') === 'fa-arrow-up'}"><use href="#project-icon-arrow"></use></svg></button></div>
|
|
<div role="columnheader" aria-label="Actions"></div>
|
|
</div>
|
|
<div class="paper-table-row" role="row" v-for="(repo, index) in filteredRepositories" :class="{'repo-inactive': repo?.status == 'expired' || repo?.status == 'removed', 'repo-error': repo?.status == 'error', 'row-selected': selected[repo?.repoId]}">
|
|
<div role="cell" style="width: 28px;">
|
|
<input type="checkbox" aria-label="Select repository" v-field="{ state: viewState, set: value => { selected[repo.repoId] = value }, value: selected[repo?.repoId], form: null, options: {} }">
|
|
</div>
|
|
<div class="cell-anon" role="cell">
|
|
<svg class="ui-icon record-type-icon" role="img" aria-label="Repository"><use href="#project-type-repo"></use></svg>
|
|
<div class="anon-text">
|
|
<a class="repo-name" target="_blank" v-text="repo?.repoId" :href="safeUrl("/r/" + (repo?.repoId))"></a>
|
|
<div class="anon-sub">
|
|
<a v-text="repo?.source?.fullName" :href="safeUrl("https://github.com/" + (repo?.source?.fullName) + "/")"></a><span v-if="repo?.options?.update"> · <a v-text="repo?.source?.branch" :href="safeUrl("https://github.com/" + (repo?.source?.fullName) + "/tree/" + (repo?.source?.branch))"></a></span><span v-if="!repo?.options?.update"> · @<a v-text="repo?.source?.commit?.substring(0, 8)" :href="safeUrl("https://github.com/" + (repo?.source?.fullName) + "/tree/" + (repo?.source?.commit))"></a></span><span v-if="repo?.conference"> · <svg class="ui-icon" aria-hidden="true"><use href="#project-icon-conference"></use></svg> {{ repo?.conference }}</span><span> · {{ fmt?.humanFileSize(repo?.size?.storage) }}</span><span> · {{ fmt?.number(repo?.options?.terms?.length) }} {{ repo?.options?.terms?.length === 1 ? 'term' : 'terms' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="cell-status" role="cell">
|
|
<span class="status-line">
|
|
<span class="status-dot" :class="{'status-removed': repo?.status == 'removed' || repo?.status == 'expired', 'status-ready': repo?.status == 'ready', 'status-error': repo?.status == 'error', 'status-preparing': repo?.status == 'preparing'}"></span>
|
|
<span v-text="fmt?.title(repo?.status)"></span>
|
|
</span>
|
|
<span class="status-sub" v-if="repo?.statusMessage" v-text="fmt?.statusMsg(repo?.statusMessage)" :title="(repo?.statusMessage)"></span>
|
|
</div>
|
|
<div class="cell-views num" role="cell" v-text="fmt?.number(repo?.pageView || 0)"></div>
|
|
<div class="cell-expires" role="cell" v-text="fmt?.humanTime(repo?.anonymizeDate)"></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("/anonymize/" + (repo?.repoId))"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-edit"></use></svg> Edit</a>
|
|
<a class="dropdown-item" :href="safeUrl("/r/" + (repo?.repoId) + "/")"><svg class="ui-icon" aria-hidden="true"><use href="#project-icon-eye"></use></svg> View repo</a>
|
|
<a class="dropdown-item" target="_self" v-if="repo?.options?.page && repo?.status == 'ready'" :href="safeUrl("/w/" + (repo?.repoId) + "/")"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-globe"></use></svg> View page</a>
|
|
<a class="dropdown-item" href="#" @click.prevent="fetchGithubInfo(repo)"><i class="fab fa-github"></i> Live GitHub info</a>
|
|
<div class="dropdown-divider"></div>
|
|
<a class="dropdown-item" href="#" v-show="repo?.status == 'ready' || repo?.status == 'error'" @click.prevent="updateRepository(repo)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-refresh"></use></svg> Force update</a>
|
|
<a class="dropdown-item" href="#" v-show="repo?.status == 'removed'" @click.prevent="updateRepository(repo)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-check-circle"></use></svg> Enable</a>
|
|
<a class="dropdown-item" href="#" v-show="repo?.statusMessage" @click.prevent="showStatusMessage(repo)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-warning"></use></svg> View status message</a>
|
|
<div class="dropdown-divider"></div>
|
|
<a class="dropdown-item" href="#" @click.prevent="removeCache(repo)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-broom"></use></svg> Remove cache</a>
|
|
<a class="dropdown-item text-danger" href="#" v-show="repo?.status == 'ready'" @click.prevent="removeRepository(repo)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-trash"></use></svg> Remove</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="paper-table-empty" v-if="filteredRepositories?.length == 0">
|
|
<svg class="ui-icon" aria-hidden="true"><use href="#project-icon-inbox"></use></svg>
|
|
<span>No repositories match the current filters.</span>
|
|
</div>
|
|
</div>
|
|
</div>
|