feat: clarify the menus and rework the settings page

"Report an issue" in the navbar read like a way to report an anonymized
repository, and it opened a blank GitHub issue instead of the template
that asks reporters to confirm the issue is about the service. The user
menu offered "Default settings" and "Logout" with no hint of what lives
behind them, and the Claim page was unreachable from any menu.

The settings page never loaded quotas (the bars stayed in their striped
loading state), mixed serif checkbox labels with sans forms, used
Bootstrap alert colours, and described options in jargon ("Proxy mode",
"Github page", "Line of code").

- Navbar: "Report a bug" links to the issue template with a tooltip that
  names the service; the user menu shows who is signed in and lists
  Settings, Claim an anonymization, Report a bug, Source code, Sign out,
  each with a one-line hint. Icon links get aria-labels.
- Settings: quotas load through a shared quotaService (also used by the
  dashboard); sections match the side navigation, which now follows
  scrolling via a small paperScrollspy directive; copy explains each
  option; account card with Sign out; delete moved into a danger zone;
  Save shows Saving/Saved and the error alert uses the paper palette.
- Conferences list and detail: sort and status buttons show their state,
  hidden statuses appear as chips with a count line, Remove is a button
  behind a divider, dates spell the month, expiry column matches the
  dashboard, detail page gets an Edit button and plan/cost meta.
- New conference: validation errors only appear after a field is touched
  or the form submitted; pricing copy matches how price is computed.
- Status page: progress label no longer overlaps the details below it;
  "Report a bug" points at the template and says whom it is for.
- Claim: single rule, constrained width, explains when to use it.
- Anonymize: shorter placeholder, consistent Auto-update label.
- Mock server gains the endpoints these pages need and an ANON mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
tdurieux
2026-09-08 20:55:22 +02:00
co-authored by Claude Fable 5.1
parent 8e9b5b9a45
commit a02e83e48c
17 changed files with 611 additions and 288 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"core.min.js": "core.6332b3c288.min.js",
"vendor.min.js": "vendor.6cf1035e61.min.js",
"vendor.min.js": "vendor.81d3dd59f8.min.js",
"mermaid.min.js": "mermaid.f848a72d16.min.js",
"all.min.css": "all.8084ca12ce.min.css"
"all.min.css": "all.6d950dfd4d.min.css"
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+86 -5
View File
@@ -3415,16 +3415,26 @@ code {
}
/* Paper progress bar */
/* The track is a pseudo-element so the label below it lives in normal flow
instead of overflowing an 8px-high box into the next section. */
.paper-progress {
position: relative;
height: auto;
background: none;
margin: 18px 0 8px;
}
.paper-progress::before {
content: "";
display: block;
height: 8px;
background: var(--paper-bg-alt);
border-radius: 999px;
overflow: visible;
margin: 18px 0 8px;
}
.paper-progress .paper-progress-bar {
height: 100%;
position: absolute;
top: 0;
left: 0;
height: 8px;
background: var(--color);
border-radius: 999px;
transition: width 0.4s ease;
@@ -3991,7 +4001,7 @@ code {
.paper-section-eyebrow {
font-family: var(--font-mono);
font-size: 10.5px;
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-muted);
@@ -4413,10 +4423,81 @@ textarea::selection {
.paper-settings-section .form-group { margin-bottom: 18px; }
.paper-settings-section .form-check { padding: 10px 0 10px 28px; border-bottom: 1px solid var(--border-soft, var(--border-color)); }
.paper-settings-section .form-check:last-child { border-bottom: none; }
.paper-settings-section .form-check-label { font-family: var(--font-serif); font-size: 16px; color: var(--color); }
/* Checkbox labels are UI text, so they use the sans face like the rest of
the forms; the serif stays reserved for display headings. */
.paper-settings-section .form-check-label { font-family: var(--font-sans); font-size: 14px; font-weight: 500; color: var(--color); cursor: pointer; }
.paper-settings-section .form-check-input { cursor: pointer; width: 16px; height: 16px; margin-top: 3px; margin-left: -28px; accent-color: var(--color); }
.paper-settings-section .form-text { color: var(--ink-soft); font-size: 13px; margin-top: 4px; max-width: 48em; }
.paper-settings-section .paper-section-copy { margin: -4px 0 14px; }
.paper-settings-footer { padding-top: 24px; display: flex; flex-direction: column; gap: 14px; }
.paper-settings-footer .btn-ink { align-self: flex-start; }
.paper-settings-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }
.paper-settings-hint { font-size: 13px; color: var(--ink-muted); }
/* Inline alerts in the paper palette (Bootstrap's .alert colours clash) */
.paper-inline-alert {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: var(--radius-control, 6px);
border: 1px solid var(--border-color);
background: var(--paper-bg-alt);
font-size: 13px;
color: var(--color);
}
.paper-inline-alert i { margin-top: 2px; color: var(--ink-muted); }
.paper-inline-alert-error { border-color: var(--status-error); background: var(--status-error-soft); }
.paper-inline-alert-error i { color: var(--status-error); }
/* Account card and danger zone on the settings page */
.paper-account-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-surface, 10px);
background: var(--paper-card);
margin-bottom: 20px;
}
.paper-account-card img { flex-shrink: 0; }
.paper-account-identity { flex: 1 1 auto; min-width: 0; }
.paper-account-name { font-family: var(--font-mono); font-size: 14px; font-weight: 500; color: var(--color); }
.paper-account-meta { font-size: 13px; color: var(--ink-muted); margin-top: 2px; }
.paper-danger-zone {
padding: 16px;
border: 1px solid var(--status-error);
border-radius: var(--radius-surface, 10px);
background: var(--status-error-soft);
}
.paper-danger-head { font-weight: 600; font-size: 14px; color: var(--status-error); margin-bottom: 6px; }
.paper-danger-copy { font-size: 13px; color: var(--ink-soft); max-width: 52em; margin-bottom: 12px; }
.paper-danger-zone .paper-inline-alert { margin-bottom: 12px; }
.btn.btn-outline-danger {
background: transparent;
color: var(--status-error);
border: 1px solid var(--status-error);
font-weight: 500;
border-radius: var(--radius-control, 6px);
padding: 8px 14px;
}
.btn.btn-outline-danger:hover { background: var(--status-error); color: #fff; border-color: var(--status-error); }
/* Navbar user menu: two-line items (label + hint) */
.navbar .user-menu { min-width: 260px; }
.navbar .user-menu .dropdown-item { display: flex; align-items: flex-start; gap: 10px; padding: 8px 10px !important; }
.navbar .user-menu .dropdown-item i { width: 16px; margin-top: 3px; font-size: 13px; color: var(--ink-muted); text-align: center; }
.navbar .user-menu .dropdown-item span { display: flex; flex-direction: column; line-height: 1.3; }
.navbar .user-menu .dropdown-item small { font-size: 12px; color: var(--ink-muted); }
.navbar .user-menu .dropdown-header {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-muted);
padding: 8px 10px 6px;
}
.paper-field-label {
font-family: var(--font-mono);
font-size: 11px;
+5 -5
View File
@@ -15,13 +15,13 @@
type="text"
class="form-control form-control-lg"
ng-model="sourceUrl"
placeholder="https://github.com/owner/repo, https://github.com/owner/repo/pull/42, or https://gist.github.com/owner/abcdef…"
placeholder="https://github.com/owner/repository"
ng-model-options="{ debounce: {default: 1000, blur: 0, click: 0}, updateOn: 'default blur click' }"
ng-change="urlSelected()"
/>
</div>
<small class="form-text" style="color: var(--ink-muted);">
Paste a repository URL to anonymize a repo, a pull-request URL for a PR, or a gist URL for a gist.
Repository, pull request (…/pull/42) and gist (gist.github.com/…) URLs are all accepted.
</small>
</div>
</div>
@@ -90,7 +90,7 @@
</select>
<div class="input-group-append">
<button class="btn" type="button" ng-click="getBranches(true)" title="Refresh" data-toggle="tooltip" data-placement="bottom">
<i class="fa fa-undo"></i>
<i class="fas fa-sync" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -110,8 +110,8 @@
<div class="form-check" ng-show="detectedType">
<input class="form-check-input" type="checkbox" id="update" name="update" ng-model="options.update" />
<label class="form-check-label" for="update">Auto update</label>
<small class="form-text text-muted">Automatically update with the latest changes (hourly max).</small>
<label class="form-check-label" for="update">Auto-update from GitHub</label>
<small class="form-text text-muted">Follow the branch and pull the latest commit automatically, at most once an hour.</small>
</div>
</section>
+11 -11
View File
@@ -1,25 +1,26 @@
<div class="container paper-page">
<div class="paper-crumbs">Reference &nbsp;/&nbsp; <span class="here">Claim</span></div>
<div class="paper-crumbs"><a href="/dashboard">My work</a> &nbsp;/&nbsp; <span class="here">Claim</span></div>
<h1 class="paper-page-title">Claim an <em>anonymization</em></h1>
<p class="paper-page-lede">
Take ownership of an existing anonymized repository so it appears on your
dashboard.
</p>
<div class="paper-meta-rule"></div>
<form class="form needs-validation" name="claimForm" novalidate>
<div class="paper-settings-main claim-form">
<p class="paper-section-copy">Use this when an anonymization was created by a co-author or from another account. You must have access to the GitHub repository it was made from.</p>
<form class="form needs-validation" name="claimForm" novalidate ng-submit="claim()">
<div class="form-group">
<label class="paper-field-label" for="repoUrl">Repository URL</label>
<label class="paper-field-label" for="repoUrl">GitHub repository URL</label>
<input
type="text"
class="form-control"
name="repoUrl"
id="repoUrl"
required
ng-class="{'is-invalid': claimForm.repoUrl.$invalid}"
ng-class="{'is-invalid': claimForm.repoUrl.$invalid && (claimForm.repoUrl.$touched || claimForm.$submitted)}"
ng-model="repoUrl"
/>
<div class="invalid-feedback" ng-show="claimForm.repoUrl.$error.not_found">
The repository is not found.
No anonymization matches this repository and ID. Check both values and that you can access the repository on GitHub.
</div>
</div>
<div class="form-group">
@@ -31,11 +32,10 @@
id="repoId"
required
ng-model="repoId"
ng-class="{'is-invalid': claimForm.repoId.$invalid}"
ng-class="{'is-invalid': claimForm.repoId.$invalid && (claimForm.repoId.$touched || claimForm.$submitted)}"
/>
<small id="idHelp" class="form-text text-muted"
>The id is in the repository
https://anonymous.4open.science/r/{id}</small
>The ID is the last part of the anonymized URL: <code>anonymous.4open.science/r/&lt;ID&gt;</code>.</small
>
</div>
@@ -43,9 +43,9 @@
id="submit"
type="submit"
class="btn btn-ink"
ng-click="claim()"
>
Claim
Claim anonymization
</button>
</form>
</div>
</div>
+74 -50
View File
@@ -1,45 +1,46 @@
<div class="container page paper-page">
<div>
<div class="paper-crumbs">
<a href="/conferences">Conferences</a> &nbsp;/&nbsp; <span class="here">{{conference.conferenceID}}</span>
<a href="/dashboard">My work</a> &nbsp;/&nbsp; <a href="/conferences">Conferences</a> &nbsp;/&nbsp; <span class="here">{{conference.conferenceID}}</span>
</div>
<div class="d-flex align-items-end flex-wrap" style="gap: 12px; justify-content: space-between;">
<div>
<h1 class="paper-page-title">
<a ng-href="{{conference.url}}" style="color: var(--color);">{{conference.name}}</a>
</h1>
<h1 class="paper-page-title" ng-bind="conference.name"></h1>
<p class="paper-page-lede" ng-if="conference.url">
<a ng-href="{{conference.url}}" ng-bind="conference.url"></a>
<a ng-href="{{conference.url}}" target="_blank" rel="noopener" ng-bind="conference.url"></a>
</p>
</div>
<span class="status-dot-wrap">
<span class="status-dot" ng-class="{'status-removed': conference.status == 'removed' || conference.status == 'expired', 'status-ready': conference.status == 'ready'}"></span>
<span ng-bind="conference.status | title"></span>
</span>
<div class="d-flex align-items-center flex-wrap" style="gap: 10px;">
<span class="status-pill" ng-class="{'status-pill-ready': conference.status == 'ready', 'status-pill-removed': conference.status == 'removed' || conference.status == 'expired'}">
<span class="status-dot" ng-class="'status-' + conference.status" aria-hidden="true"></span>
<span ng-bind="conference.status | statusLabel"></span>
</span>
<a class="btn btn-outline-ink" ng-href="/conference/{{conference.conferenceID}}/edit"><i class="far fa-edit mr-1" aria-hidden="true"></i> Edit conference</a>
</div>
</div>
<div class="paper-meta-rule">
<span>ID <b ng-bind="conference.conferenceID"></b></span>
<span>From <b>{{conference.startDate | date}}</b></span>
<span>Ends <b>{{conference.endDate | date}}</b></span>
<span>Repos <b>{{::conference.repositories.length | number}}</b></span>
<span>Price <b>{{conference.price || 0 | number}} &euro;</b></span>
<span>ID <b class="commit-hash" ng-bind="conference.conferenceID"></b></span>
<span>Review window <b>{{conference.startDate | date:'mediumDate'}} &ndash; {{conference.endDate | date:'mediumDate'}}</b></span>
<span>Repositories <b>{{conference.repositories.length | number}}</b></span>
<span>Plan <b ng-bind="conference.plan.name || conference.plan.planID || 'Free'"></b></span>
<span ng-if="conference.price">Cost so far <b>{{conference.price | number:2}} &euro;</b></span>
</div>
<div class="paper-section-eyebrow">Repositories</div>
<form class="w-100 dashboard-filter-row" aria-label="Repositories" accept-charset="UTF-8">
<form class="w-100 dashboard-filter-row" aria-label="Filter repositories" accept-charset="UTF-8" ng-submit="$event.preventDefault()">
<div class="search-wrap">
<input
type="search"
id="search"
class="form-control"
aria-label="Find repositories"
placeholder="Find repositories…"
aria-label="Search repositories"
placeholder="Search by ID or source repository…"
autocomplete="off"
ng-model="search"
/>
</div>
<div class="d-flex flex-wrap" style="gap: 8px">
<div class="dashboard-filter-controls">
<div class="dropdown">
<button
class="btn dropdown-toggle"
@@ -49,13 +50,14 @@
aria-haspopup="true"
aria-expanded="false"
>
Sort
<span class="filter-btn-label">Sort</span>
<span class="filter-btn-value">{{ ({'-anonymizeDate': 'Anonymize date', repoId: 'ID', '-status': 'Status', '-pageView': 'Views'})[orderBy] || 'Custom' }}</span>
</button>
<div class="dropdown-menu" aria-labelledby="dropdownSort">
<h6 class="dropdown-header">Select order</h6>
<h6 class="dropdown-header">Order by</h6>
<div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="anonymizeDate" value="-anonymizeDate" ng-model="orderBy" />
<label class="form-check-label" for="anonymizeDate">Anonymize Date</label>
<label class="form-check-label" for="anonymizeDate">Anonymize date</label>
</div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="sortID" value="repoId" ng-model="orderBy" />
@@ -65,10 +67,14 @@
<input class="form-check-input" type="radio" name="sort" id="sortStatus" value="-status" ng-model="orderBy" />
<label class="form-check-label" for="sortStatus">Status</label>
</div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="sortViews" value="-pageView" ng-model="orderBy" />
<label class="form-check-label" for="sortViews">Views</label>
</div>
</div>
</div>
<div class="dropdown">
<div class="dropdown" ng-init="statusLabels = {ready: 'Ready', expired: 'Expired', removed: 'Removed'}">
<button
class="btn dropdown-toggle"
type="button"
@@ -77,58 +83,72 @@
aria-haspopup="true"
aria-expanded="false"
>
Status
<span class="filter-btn-label">Status</span>
<span class="filter-btn-value" ng-if="filters.status.ready && filters.status.expired && filters.status.removed">All</span>
<span class="filter-btn-value" ng-if="!(filters.status.ready && filters.status.expired && filters.status.removed)">
{{ (filters.status.ready ? 0 : 1) + (filters.status.expired ? 0 : 1) + (filters.status.removed ? 0 : 1) }} hidden
</span>
</button>
<div class="dropdown-menu" aria-labelledby="dropdownStatus">
<h6 class="dropdown-header">Select status</h6>
<div class="form-check dropdown-item">
<input class="form-check-input" type="checkbox" name="sort" id="statusReady" value="ready" ng-model="filters.status.ready" />
<label class="form-check-label" for="statusReady">Ready</label>
</div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="checkbox" name="sort" id="statusExpired" value="expired" ng-model="filters.status.expired" />
<label class="form-check-label" for="statusExpired">Expired</label>
</div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="checkbox" name="sort" id="statusRemoved" value="removed" ng-model="filters.status.removed" />
<label class="form-check-label" for="statusRemoved">Removed</label>
<h6 class="dropdown-header">Show statuses</h6>
<div class="form-check dropdown-item" ng-repeat="(key, label) in statusLabels">
<input class="form-check-input" type="checkbox" id="status-{{key}}" ng-model="filters.status[key]" />
<label class="form-check-label" for="status-{{key}}">{{label}}</label>
</div>
</div>
</div>
</div>
</form>
<div class="dashboard-meta">
<span class="dashboard-count">
<span ng-if="filteredRepositories.length === conference.repositories.length">{{conference.repositories.length | number}} repositor<span ng-if="conference.repositories.length === 1">y</span><span ng-if="conference.repositories.length !== 1">ies</span></span>
<span ng-if="filteredRepositories.length !== conference.repositories.length">{{filteredRepositories.length | number}} of {{conference.repositories.length | number}} shown</span>
</span>
<span class="filter-chip" ng-repeat="(f, v) in filters.status" ng-if="v === false">
Hiding {{statusLabels[f]}}
<button type="button" class="filter-chip-close" aria-label="Show {{statusLabels[f]}} again" ng-click="filters.status[f] = true;">&times;</button>
</span>
</div>
<div class="paper-table paper-table-repos w-100" role="table" aria-label="Repositories">
<div class="paper-table-head" role="row">
<div role="columnheader">Repository</div>
<div role="columnheader">Status</div>
<div role="columnheader" class="num">Views</div>
<div role="columnheader">Expires</div>
<div role="columnheader" aria-label="Actions"></div>
<div role="columnheader"><span class="sr-only">Actions</span></div>
</div>
<div
class="paper-table-row"
role="row"
ng-class="{'repo-inactive': repo.status == 'expired' || repo.status == 'removed'}"
ng-repeat="repo in conference.repositories| filter:repoFiler| orderBy:orderBy as filteredRepositories"
ng-class="{'repo-inactive': repo.status == 'expired' || repo.status == 'removed', 'repo-error': repo.status == 'error'}"
ng-repeat="repo in conference.repositories | filter:repoFiler | orderBy:orderBy as filteredRepositories track by repo.repoId"
>
<div class="cell-anon" role="cell">
<span class="type-badge type-repo">Repo</span>
<div class="anon-text">
<a class="repo-name" ng-href="/r/{{repo.repoId}}" ng-bind="repo.repoId"></a>
<a class="repo-name" ng-href="/r/{{repo.repoId}}/" ng-bind="repo.repoId"></a>
<div class="anon-sub">
<a href="https://github.com/{{repo.source.fullName}}/" ng-bind="repo.source.fullName"></a><span ng-if="repo.options.update">&nbsp;&middot;&nbsp;<a href="https://github.com/{{repo.source.fullName}}/tree/{{repo.source.branch}}" ng-bind="repo.source.branch"></a></span><span ng-if="!repo.options.update">&nbsp;&middot;&nbsp;@<a href="https://github.com/{{repo.source.fullName}}/tree/{{repo.source.commit}}" ng-bind="repo.source.commit.substring(0, 8)"></a></span>
<span class="anon-source">
<a href="https://github.com/{{repo.source.fullName}}/" target="_blank" rel="noopener" ng-bind="repo.source.fullName"></a><span ng-if="repo.options.update && repo.source.branch"> &middot; <a href="https://github.com/{{repo.source.fullName}}/tree/{{repo.source.branch}}" target="_blank" rel="noopener" ng-bind="repo.source.branch"></a></span><span ng-if="repo.source.commit"> &middot; <a class="commit-hash" href="https://github.com/{{repo.source.fullName}}/tree/{{repo.source.commit}}" target="_blank" rel="noopener">@{{repo.source.commit.substring(0, 8)}}</a></span>
</span>
</div>
</div>
</div>
<div class="cell-status" role="cell">
<span class="status-dot" ng-class="{'status-removed': repo.status == 'removed' || repo.status == 'expired', 'status-ready': repo.status == 'ready', 'status-error': repo.status == 'error'}"></span>
<span ng-bind="repo.status | title"></span>
<div class="status-line">
<span class="status-dot" ng-class="'status-' + (repo.status == 'error' ? 'error' : repo.status == 'ready' ? 'ready' : (repo.status == 'expired' || repo.status == 'removed') ? repo.status : 'progress')" aria-hidden="true"></span>
<span class="status-word" ng-bind="repo.status | statusLabel"></span>
</div>
<div class="status-sub" ng-if="repo.anonymizeDate">anonymized {{repo.anonymizeDate | humanTime}}</div>
</div>
<div class="cell-views num" role="cell" ng-bind="::repo.pageView | number"></div>
<div class="cell-expires" role="cell">
<span ng-if="repo.options.expirationMode !== 'never' && repo.status == 'ready'" ng-bind="repo.options.expirationDate | humanTime"></span>
<span class="empty-dash" ng-if="!(repo.options.expirationMode !== 'never' && repo.status == 'ready')">&mdash;</span>
<span class="expires-never" ng-if="repo.status == 'ready' && (repo.options.expirationMode === 'never' || !repo.options.expirationDate)">Never</span>
<span ng-if="repo.status == 'ready' && repo.options.expirationMode !== 'never' && repo.options.expirationDate" ng-bind="repo.options.expirationDate | humanTime"></span>
<span class="expires-past" ng-if="repo.status == 'expired'">Expired<span ng-if="repo.options.expirationDate"> {{repo.options.expirationDate | humanTime}}</span></span>
<span class="empty-dash" ng-if="repo.status != 'ready' && repo.status != 'expired'" aria-label="Not applicable">&mdash;</span>
</div>
<div class="cell-actions" role="cell">
<div class="dropdown">
@@ -138,24 +158,28 @@
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
aria-label="Actions"
aria-label="Actions for {{repo.repoId}}"
>
<i class="fas fa-ellipsis-h" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="/anonymize/{{repo.repoId}}">
<i class="far fa-edit" aria-hidden="true"></i> Edit
</a>
<a class="dropdown-item" href="/r/{{repo.repoId}}/">
<i class="fa fa-eye" aria-hidden="true"></i> View
</a>
<a class="dropdown-item" href="/anonymize/{{repo.repoId}}">
<i class="far fa-edit" aria-hidden="true"></i> Edit
</a>
</div>
</div>
</div>
</div>
<div class="paper-table-empty" ng-if="filteredRepositories.length == 0">
<i class="fas fa-inbox"></i>
<span>There is no repository to display.</span>
<i class="fas fa-inbox" aria-hidden="true"></i>
<span ng-if="!conference.repositories.length">No repository has been submitted to this conference yet.</span>
<span ng-if="conference.repositories.length">Nothing matches the current filters.</span>
<div class="paper-table-empty-actions" ng-if="conference.repositories.length">
<button type="button" class="btn btn-outline-ink" ng-click="search = ''; filters.status.ready = true; filters.status.expired = true; filters.status.removed = true;">Clear filters</button>
</div>
</div>
</div>
</div>
+60 -43
View File
@@ -1,30 +1,30 @@
<div class="container page paper-page">
<div class="row">
<div class="w-100">
<div class="paper-crumbs">Reference &nbsp;/&nbsp; <span class="here">Conferences</span></div>
<div class="paper-crumbs"><a href="/dashboard">My work</a> &nbsp;/&nbsp; <span class="here">Conferences</span></div>
<div class="d-flex align-items-end justify-content-between flex-wrap" style="gap: 12px;">
<div>
<h1 class="paper-page-title">Your <em>conferences</em></h1>
<p class="paper-page-lede">Group anonymizations by venue; set shared expiry and defaults.</p>
<p class="paper-page-lede">Group anonymizations by venue, give chairs a shared dashboard, and set one expiry for every submission.</p>
</div>
<a href="/conference/new" class="btn btn-ink">
<i class="fa fa-plus-circle mr-1"></i> New conference
<i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New conference
</a>
</div>
<form class="w-100 dashboard-filter-row" aria-label="Conferences" accept-charset="UTF-8">
<form class="w-100 dashboard-filter-row" aria-label="Filter conferences" accept-charset="UTF-8" ng-submit="$event.preventDefault()">
<div class="search-wrap">
<input
type="search"
id="search"
class="form-control"
aria-label="Find a conference"
placeholder="Find a conference…"
aria-label="Search conferences"
placeholder="Search by name or ID…"
autocomplete="off"
ng-model="search"
/>
</div>
<div class="d-flex flex-wrap" style="gap: 8px">
<div class="dashboard-filter-controls">
<div class="dropdown">
<button
class="btn dropdown-toggle"
@@ -34,10 +34,11 @@
aria-haspopup="true"
aria-expanded="false"
>
Sort
<span class="filter-btn-label">Sort</span>
<span class="filter-btn-value">{{ ({name: 'Name', conferenceID: 'ID', '-status': 'Status'})[orderBy] || 'Custom' }}</span>
</button>
<div class="dropdown-menu" aria-labelledby="dropdownSort">
<h6 class="dropdown-header">Select order</h6>
<h6 class="dropdown-header">Order by</h6>
<div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="sortName" value="name" ng-model="orderBy" />
<label class="form-check-label" for="sortName">Name</label>
@@ -53,7 +54,7 @@
</div>
</div>
<div class="dropdown">
<div class="dropdown" ng-init="statusLabels = {ready: 'Ready', expired: 'Expired', removed: 'Removed'}">
<button
class="btn dropdown-toggle"
type="button"
@@ -62,26 +63,33 @@
aria-haspopup="true"
aria-expanded="false"
>
Status
<span class="filter-btn-label">Status</span>
<span class="filter-btn-value" ng-if="filters.status.ready && filters.status.expired && filters.status.removed">All</span>
<span class="filter-btn-value" ng-if="!(filters.status.ready && filters.status.expired && filters.status.removed)">
{{ (filters.status.ready ? 0 : 1) + (filters.status.expired ? 0 : 1) + (filters.status.removed ? 0 : 1) }} hidden
</span>
</button>
<div class="dropdown-menu" aria-labelledby="dropdownStatus">
<h6 class="dropdown-header">Select status</h6>
<div class="form-check dropdown-item">
<input class="form-check-input" type="checkbox" name="sort" id="statusReady" value="ready" ng-model="filters.status.ready" />
<label class="form-check-label" for="statusReady">Ready</label>
</div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="checkbox" name="sort" id="statusExpired" value="expired" ng-model="filters.status.expired" />
<label class="form-check-label" for="statusExpired">Expired</label>
</div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="checkbox" name="sort" id="statusRemoved" value="removed" ng-model="filters.status.removed" />
<label class="form-check-label" for="statusRemoved">Removed</label>
<h6 class="dropdown-header">Show statuses</h6>
<div class="form-check dropdown-item" ng-repeat="(key, label) in statusLabels">
<input class="form-check-input" type="checkbox" id="status-{{key}}" ng-model="filters.status[key]" />
<label class="form-check-label" for="status-{{key}}">{{label}}</label>
</div>
</div>
</div>
</div>
</form>
<div class="dashboard-meta">
<span class="dashboard-count">
<span ng-if="filteredConferences.length === conferences.length">{{conferences.length | number}} conference<span ng-if="conferences.length !== 1">s</span></span>
<span ng-if="filteredConferences.length !== conferences.length">{{filteredConferences.length | number}} of {{conferences.length | number}} shown</span>
</span>
<span class="filter-chip" ng-repeat="(f, v) in filters.status" ng-if="v === false">
Hiding {{statusLabels[f]}}
<button type="button" class="filter-chip-close" aria-label="Show {{statusLabels[f]}} again" ng-click="filters.status[f] = true;">&times;</button>
</span>
</div>
</div>
<div class="paper-table paper-table-conferences w-100" role="table" aria-label="Conferences">
@@ -89,63 +97,72 @@
<div role="columnheader">Conference</div>
<div role="columnheader">Status</div>
<div role="columnheader" class="num">Repos</div>
<div role="columnheader">Window</div>
<div role="columnheader" aria-label="Actions"></div>
<div role="columnheader">Review window</div>
<div role="columnheader"><span class="sr-only">Actions</span></div>
</div>
<div
class="paper-table-row"
class="paper-table-row row-clickable"
role="row"
ng-class="{'repo-inactive': conference.status == 'expired' || conference.status == 'removed'}"
ng-repeat="conference in conferences| filter:conferenceFilter| orderBy:orderBy as filteredConferences"
ng-repeat="conference in conferences | filter:conferenceFilter | orderBy:orderBy as filteredConferences track by conference.conferenceID"
>
<div class="cell-anon" role="cell">
<span class="type-badge type-repo">Conf</span>
<div class="anon-text">
<a class="repo-name" ng-href="/conference/{{conference.conferenceID}}" ng-bind="conference.name"></a>
<div class="anon-sub">
ID <a ng-href="/conference/{{conference.conferenceID}}" ng-bind="conference.conferenceID"></a>
<span class="anon-source">ID <span class="commit-hash" ng-bind="conference.conferenceID"></span><span ng-if="conference.url"> &middot; <a ng-href="{{conference.url}}" target="_blank" rel="noopener" ng-bind="conference.url | limitTo: 60"></a></span></span>
</div>
</div>
</div>
<div class="cell-status" role="cell">
<span class="status-dot" ng-class="{'status-removed': conference.status == 'removed' || conference.status == 'expired', 'status-ready': conference.status == 'ready'}"></span>
<span ng-bind="conference.status | title"></span>
<div class="status-line">
<span class="status-dot" ng-class="'status-' + conference.status" aria-hidden="true"></span>
<span class="status-word" ng-bind="conference.status | statusLabel"></span>
</div>
</div>
<div class="cell-views num" role="cell" ng-bind="::conference.nbRepositories || 0 | number"></div>
<div class="cell-expires" role="cell">
<span ng-if="conference.startDate">{{conference.startDate | date}} &ndash; {{conference.endDate | date}}</span>
<span class="empty-dash" ng-if="!conference.startDate">&mdash;</span>
<span ng-if="conference.startDate">{{conference.startDate | date:'mediumDate'}} &ndash; {{conference.endDate | date:'mediumDate'}}</span>
<span class="empty-dash" ng-if="!conference.startDate" aria-label="No review window">&mdash;</span>
</div>
<div class="cell-actions" role="cell">
<div class="dropdown">
<button
class="btn btn-icon-dots"
type="button"
id="dropdownMenuButton"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
aria-label="Actions"
aria-label="Actions for {{conference.name}}"
>
<i class="fas fa-ellipsis-h" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="/conference/{{conference.conferenceID}}/">
<i class="fa fa-eye" aria-hidden="true"></i> View
</a>
<a class="dropdown-item" href="/conference/{{conference.conferenceID}}/edit">
<i class="far fa-edit" aria-hidden="true"></i> Edit
</a>
<a class="dropdown-item" href="#" ng-show="conference.status != 'removed'" ng-click="removeConference(conference)">
<i class="fas fa-trash-alt"></i> Remove
</a>
<a class="dropdown-item" href="/conference/{{conference.conferenceID}}/">
<i class="fa fa-eye" aria-hidden="true"></i> View conference
</a>
<div ng-if="conference.status != 'removed'">
<div class="dropdown-divider"></div>
<button type="button" class="dropdown-item dropdown-item-danger" ng-click="removeConference(conference)">
<i class="fas fa-trash-alt" aria-hidden="true"></i> Remove
</button>
</div>
</div>
</div>
</div>
</div>
<div class="paper-table-empty" ng-if="filteredConferences.length == 0">
<i class="fas fa-inbox"></i>
<span>There are no conference entries. You can create a <a href="/conference/new">new one</a>.</span>
<i class="fas fa-inbox" aria-hidden="true"></i>
<span ng-if="conferences.length == 0">You have not created a conference yet.</span>
<span ng-if="conferences.length > 0">Nothing matches the current filters.</span>
<div class="paper-table-empty-actions">
<button type="button" class="btn btn-outline-ink" ng-if="conferences.length > 0" ng-click="search = ''; filters.status.ready = true; filters.status.expired = true; filters.status.removed = true;">Clear filters</button>
<a href="/conference/new" class="btn btn-ink" ng-if="conferences.length == 0"><i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New conference</a>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@
<h1 class="paper-faq-title">Answers to the <em>questions</em> that come up most.</h1>
<p class="paper-faq-lede">
If something's missing, write to us &mdash;
<a href="https://github.com/tdurieux/anonymous_github/issues">open an issue on GitHub</a>.
<a href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">report a bug on GitHub</a>.
</p>
</div>
<div class="paper-faq-body">
+37 -12
View File
@@ -74,30 +74,37 @@
class="nav-link nav-icon"
target="_blank"
href="https://github.com/tdurieux/anonymous_github/"
title="GitHub repository"
title="Anonymous GitHub source code"
aria-label="Anonymous GitHub source code on GitHub"
rel="noopener"
data-offset="30"
><i class="fab fa-github" aria-hidden="true"></i
></a>
</li>
<!-- "Report a bug" is about this service. Reviewers who land on an
anonymized repository often think it is about the paper, so the
label names the service and the tooltip says who to contact. -->
<li class="nav-item d-none d-lg-block">
<a
class="nav-link"
target="_blank"
href="https://github.com/tdurieux/anonymous_github/issues/new"
rel="noopener"
href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml"
title="Report a problem with the Anonymous GitHub service. Questions about an anonymized repository or paper go to its authors."
data-offset="30"
>Report an issue
><i class="fas fa-bug d-lg-none mr-1" aria-hidden="true"></i>Report a bug
</a>
</li>
<li class="nav-item" ng-if="!isDarkMode">
<a class="nav-link nav-icon" href="#" ng-click="darkMode(true);" title="Dark mode">
<i class="fas fa-moon"></i>
<a class="nav-link nav-icon" href="#" ng-click="darkMode(true);" title="Switch to dark mode" aria-label="Switch to dark mode" role="button">
<i class="fas fa-moon" aria-hidden="true"></i>
</a>
</li>
<li class="nav-item" ng-if="isDarkMode">
<a class="nav-link nav-icon" href="#" ng-click="darkMode(false);" title="Light mode">
<i class="fas fa-sun"></i>
<a class="nav-link nav-icon" href="#" ng-click="darkMode(false);" title="Switch to light mode" aria-label="Switch to light mode" role="button">
<i class="fas fa-sun" aria-hidden="true"></i>
</a>
</li>
@@ -126,12 +133,30 @@
/>
<span class="user-chip-name">{{user.username}}</span>
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="/profile">Default settings</a>
<div class="dropdown-menu dropdown-menu-right user-menu" aria-labelledby="navbarDropdownMenuLink">
<h6 class="dropdown-header">Signed in as @{{user.username}}</h6>
<a class="dropdown-item" href="/profile">
<i class="fas fa-sliders-h" aria-hidden="true"></i>
<span>Settings<small>Defaults, quotas, account</small></span>
</a>
<a class="dropdown-item" href="/claim">
<i class="fas fa-link" aria-hidden="true"></i>
<span>Claim an anonymization<small>Attach an existing mirror to your account</small></span>
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="/api/user/logout" target="_self"
>Logout</a
>
<a class="dropdown-item" href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">
<i class="fas fa-bug" aria-hidden="true"></i>
<span>Report a bug<small>About this service, not a paper</small></span>
</a>
<a class="dropdown-item" href="https://github.com/tdurieux/anonymous_github/" target="_blank" rel="noopener">
<i class="fab fa-github" aria-hidden="true"></i>
<span>Source code</span>
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="/api/user/logout" target="_self">
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
<span>Sign out</span>
</a>
</div>
</li>
</ul>
+3 -3
View File
@@ -7,8 +7,8 @@
<span class="accent">not the author.</span>
</h1>
<p class="hero-subtitle">
Anonymous GitHub makes a read-only mirror of your repository or pull
request with every trace of identity removed &mdash; so you can attach a
Anonymous GitHub makes a read-only mirror of your repository, pull
request, or gist with every trace of identity removed &mdash; so you can attach a
link to your submission without breaking anonymity.
</p>
@@ -264,7 +264,7 @@
<div class="paper-footer-col">
<div class="paper-footer-head">Project</div>
<a href="https://github.com/tdurieux/anonymous_github/" target="_blank">Source code</a>
<a href="https://github.com/tdurieux/anonymous_github/issues/new" target="_blank">Report an issue</a>
<a href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">Report a bug</a>
<a href="https://github.com/sponsors/tdurieux/" target="_blank">Sponsor</a>
</div>
<div class="paper-footer-col">
+19 -19
View File
@@ -1,5 +1,5 @@
<div class="container paper-page paper-settings">
<div class="paper-crumbs"><a href="/conferences">Conferences</a> &nbsp;/&nbsp; <span class="here">{{editionMode ? 'Edit' : 'New'}}</span></div>
<div class="paper-crumbs"><a href="/dashboard">My work</a> &nbsp;/&nbsp; <a href="/conferences">Conferences</a> &nbsp;/&nbsp; <span class="here">{{editionMode ? 'Edit' : 'New'}}</span></div>
<h1 class="paper-page-title">{{editionMode ? 'Edit' : 'Create a'}} <em>conference</em></h1>
<p class="paper-page-lede">
Give chairs access to every anonymization submitted to a venue, and lift
@@ -7,8 +7,8 @@
</p>
<div class="paper-settings-body">
<aside class="paper-settings-toc">
<div class="paper-settings-toc-head">Contents</div>
<aside class="paper-settings-toc" paper-scrollspy>
<div class="paper-settings-toc-head">On this page</div>
<nav>
<a href="#conf-basics">Basics</a>
<a href="#conf-window">Review window</a>
@@ -31,7 +31,7 @@
name="name"
id="name"
required
ng-class="{'is-invalid': conference.name.$invalid}"
ng-class="{'is-invalid': conference.name.$invalid && (conference.name.$touched || conference.$submitted)}"
ng-model="options.name"
placeholder="e.g. International Conference on Software Engineering"
/>
@@ -50,7 +50,7 @@
class="form-control"
name="url"
id="url"
ng-class="{'is-invalid': conference.url.$invalid}"
ng-class="{'is-invalid': conference.url.$invalid && (conference.url.$touched || conference.$submitted)}"
ng-model="options.url"
placeholder="https://example.org"
/>
@@ -68,7 +68,7 @@
id="conferenceID"
required
pattern="[a-zA-Z0-9\-_]{3,10}"
ng-class="{'is-invalid': conference.conferenceID.$invalid}"
ng-class="{'is-invalid': conference.conferenceID.$invalid && (conference.conferenceID.$touched || conference.$submitted)}"
ng-model="options.conferenceID"
placeholder="ICSE26"
/>
@@ -97,7 +97,7 @@
name="startDate"
id="startDate"
required
ng-class="{'is-invalid': conference.startDate.$invalid}"
ng-class="{'is-invalid': conference.startDate.$invalid && (conference.startDate.$touched || conference.$submitted)}"
ng-model="options.startDate"
/>
<small class="form-text text-muted">Beginning of the review process.</small>
@@ -117,7 +117,7 @@
name="endDate"
id="endDate"
required
ng-class="{'is-invalid': conference.endDate.$invalid}"
ng-class="{'is-invalid': conference.endDate.$invalid && (conference.endDate.$touched || conference.$submitted)}"
ng-model="options.endDate"
/>
<small class="form-text text-muted">All repositories expire on this date.</small>
@@ -160,19 +160,19 @@
<div class="form-check">
<input class="form-check-input" type="checkbox" id="update" name="update" ng-model="options.options.update" />
<label class="form-check-label" for="update">Auto update</label>
<label class="form-check-label" for="update">Auto-update from GitHub</label>
<small class="form-text text-muted">Pull the latest commit automatically (hourly maximum).</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="page" name="page" ng-model="options.options.page" />
<label class="form-check-label" for="page">GitHub pages</label>
<label class="form-check-label" for="page">GitHub Pages</label>
<small class="form-text text-muted">Enable anonymized GitHub pages.</small>
</div>
</section>
<section id="conf-plan" class="paper-settings-section">
<div class="paper-section-eyebrow">Plan</div>
<p class="paper-settings-copy">Repositories are billed per hour.</p>
<p class="paper-section-copy">Billed per repository per month, prorated by the day for as long as a repository stays in the conference.</p>
<div class="paper-plan-grid">
<label
@@ -202,14 +202,14 @@
<div class="form-group">
<label class="paper-field-label" for="billing_name">Name</label>
<input type="text" class="form-control" name="billing_name" id="billing_name" required
ng-class="{'is-invalid': conference.billing_name.$invalid}"
ng-class="{'is-invalid': conference.billing_name.$invalid && (conference.billing_name.$touched || conference.$submitted)}"
ng-model="options.billing.name" placeholder="First &amp; last name" />
</div>
<div class="form-group">
<label class="paper-field-label" for="email">Email</label>
<input type="email" class="form-control" name="email" id="email" required
ng-class="{'is-invalid': conference.email.$invalid}"
ng-class="{'is-invalid': conference.email.$invalid && (conference.email.$touched || conference.$submitted)}"
ng-model="options.billing.email" placeholder="you@example.org" />
</div>
@@ -218,12 +218,12 @@
<input type="text" class="form-control" name="inputAddress" id="inputAddress" required
placeholder="1234 Main St"
ng-model="options.billing.address"
ng-class="{'is-invalid': conference.inputAddress.$invalid}" />
ng-class="{'is-invalid': conference.inputAddress.$invalid && (conference.inputAddress.$touched || conference.$submitted)}" />
</div>
<div class="form-group">
<input type="text" class="form-control" name="inputAddress2" id="inputAddress2"
ng-model="options.billing.address2"
ng-class="{'is-invalid': conference.inputAddress2.$invalid}"
ng-class="{'is-invalid': conference.inputAddress2.$invalid && (conference.inputAddress2.$touched || conference.$submitted)}"
placeholder="Apartment, studio, or floor" />
</div>
@@ -232,26 +232,26 @@
<label class="paper-field-label" for="city">City</label>
<input type="text" class="form-control" name="city" id="city"
ng-model="options.billing.city" required
ng-class="{'is-invalid': conference.city.$invalid}" placeholder="City" />
ng-class="{'is-invalid': conference.city.$invalid && (conference.city.$touched || conference.$submitted)}" placeholder="City" />
</div>
<div class="form-group">
<label class="paper-field-label" for="country">Country</label>
<input type="text" class="form-control" name="country" id="country"
ng-model="options.billing.country" required
ng-class="{'is-invalid': conference.country.$invalid}" placeholder="Country" />
ng-class="{'is-invalid': conference.country.$invalid && (conference.country.$touched || conference.$submitted)}" placeholder="Country" />
</div>
<div class="form-group">
<label class="paper-field-label" for="zip">Zip</label>
<input type="text" class="form-control" name="zip" id="zip"
ng-model="options.billing.zip" required
ng-class="{'is-invalid': conference.zip.$invalid}" placeholder="Zip" />
ng-class="{'is-invalid': conference.zip.$invalid && (conference.zip.$touched || conference.$submitted)}" placeholder="Zip" />
</div>
</div>
<div class="form-group">
<label class="paper-field-label" for="vat">VAT number</label>
<input type="text" class="form-control" name="vat" id="vat"
ng-class="{'is-invalid': conference.vat.$invalid}"
ng-class="{'is-invalid': conference.vat.$invalid && (conference.vat.$touched || conference.$submitted)}"
ng-model="options.billing.vat" placeholder="VAT Number" />
</div>
</section>
+106 -95
View File
@@ -1,15 +1,15 @@
<div class="container paper-page paper-settings">
<div class="paper-crumbs">Reference &nbsp;/&nbsp; <span class="here">Settings</span></div>
<div class="paper-crumbs"><a href="/dashboard">My work</a> &nbsp;/&nbsp; <span class="here">Settings</span></div>
<h1 class="paper-page-title">Your <em>settings</em></h1>
<p class="paper-page-lede">Review quotas and set defaults applied to every new anonymization.</p>
<p class="paper-page-lede">Defaults applied to every new anonymization, your quotas, and your account.</p>
<div class="paper-settings-body">
<aside class="paper-settings-toc">
<div class="paper-settings-toc-head">Contents</div>
<aside class="paper-settings-toc" paper-scrollspy>
<div class="paper-settings-toc-head">On this page</div>
<nav>
<a href="#settings-quota">Quota</a>
<a href="#settings-terms">Terms</a>
<a href="#settings-rendering">Rendering</a>
<a href="#settings-terms">Terms to redact</a>
<a href="#settings-display">Display</a>
<a href="#settings-features">Features</a>
<a href="#settings-expiration">Expiration</a>
<a href="#settings-account">Account</a>
@@ -17,170 +17,181 @@
</aside>
<div class="paper-settings-main">
<!-- Quota: same markup as the dashboard, driven by quotaService -->
<section id="settings-quota" class="paper-settings-section">
<div class="paper-section-eyebrow">Quota</div>
<div class="quota-row">
<div class="quota-item">
<p class="paper-section-copy">What you are using across all your anonymizations. Conferences can lift these limits during a review window.</p>
<div class="quota-row" ng-if="quota">
<div class="quota-item" ng-repeat="q in [
{key: 'repository', label: 'Repositories', kind: 'count'},
{key: 'storage', label: 'Storage', kind: 'bytes'},
{key: 'file', label: 'Files', kind: 'count'}
]">
<div class="quota-header">
<span class="quota-label">Repositories</span>
<span class="quota-value" ng-show="quota">{{quota.repository.used | number}}/{{quota.repository.total}}</span>
<span class="quota-label">{{q.label}}</span>
<span class="quota-value" ng-if="q.kind === 'count'">
{{quota[q.key].used | number}}<span ng-if="!quota[q.key].unlimited"> / {{quota[q.key].total | number}}</span>
<span class="quota-unlimited-tag" ng-if="quota[q.key].unlimited">Unlimited</span>
</span>
<span class="quota-value" ng-if="q.kind === 'bytes'">
{{quota[q.key].used | humanFileSize}}<span ng-if="!quota[q.key].unlimited"> / {{quota[q.key].total | humanFileSize}}</span>
<span class="quota-unlimited-tag" ng-if="quota[q.key].unlimited">Unlimited</span>
</span>
</div>
<div class="progress quota-progress">
<div
class="progress-bar"
ng-class="{'progress-bar-striped progress-bar-animated w-100 bg-dark': !quota, 'bg-success': quota.repository.percent < 25 || quota.repository.total == 0, 'bg-danger': quota.repository.percent > 95 && quota.repository.total > 0, 'bg-warning': quota.repository.percent > 75 && quota.repository.total > 0 }"
role="progressbar"
style="width: {{quota.repository.percent}}%;"
aria-valuenow="{{quota.repository.used}}"
aria-valuemin="0"
aria-valuemax="{{quota.repository.total}}"
></div>
<div
class="quota-track"
ng-class="'quota-' + quota[q.key].level"
role="progressbar"
aria-label="{{q.label}} quota"
aria-valuenow="{{quota[q.key].used}}"
aria-valuemin="0"
aria-valuemax="{{quota[q.key].unlimited ? quota[q.key].used : quota[q.key].total}}"
aria-valuetext="{{quota[q.key].unlimited ? 'unlimited' : (quota[q.key].percent | number:0) + '% used'}}"
>
<div class="quota-fill" ng-if="!quota[q.key].unlimited" ng-style="{width: quota[q.key].percent + '%'}"></div>
</div>
</div>
<div class="quota-item">
<div class="quota-header">
<span class="quota-label">Storage</span>
<span class="quota-value" ng-show="quota">{{quota.storage.used | humanFileSize}}/{{quota.storage.total | humanFileSize}}</span>
</div>
<div class="progress quota-progress">
<div
class="progress-bar"
ng-class="{'progress-bar-striped progress-bar-animated w-100 bg-dark': !quota, 'bg-success': quota.storage.percent < 25 || quota.storage.total == 0, 'bg-danger': quota.storage.percent > 95 && quota.storage.total > 0, 'bg-warning': quota.storage.percent > 75 && quota.storage.total > 0 }"
role="progressbar"
style="width: {{quota.storage.percent}}%;"
aria-valuenow="{{quota.storage.used}}"
aria-valuemin="0"
aria-valuemax="{{quota.storage.total}}"
></div>
</div>
</div>
<div class="quota-item">
<div class="quota-header">
<span class="quota-label">Files</span>
<span class="quota-value" ng-show="quota">{{quota.file.used | number}}/{{quota.file.total || "&#8734;"}}</span>
</div>
<div class="progress quota-progress">
<div
class="progress-bar"
ng-class="{'progress-bar-striped progress-bar-animated w-100 bg-dark': !quota, 'bg-success': quota.file.percent < 25 || quota.file.total == 0, 'bg-danger': quota.file.percent > 95 && quota.file.total > 0, 'bg-warning': quota.file.percent > 75 && quota.file.total > 0 }"
role="progressbar"
style="width: {{quota.file.percent}}%;"
aria-valuenow="{{quota.file.used}}"
aria-valuemin="0"
aria-valuemax="{{quota.file.total}}"
></div>
</div>
</div>
<div class="quota-row" ng-if="!quota" aria-hidden="true">
<div class="quota-item" ng-repeat="i in [1, 2, 3]">
<div class="quota-header"><span class="skeleton skeleton-line" style="width: 40%"></span><span class="skeleton skeleton-line" style="width: 25%"></span></div>
<div class="quota-track"></div>
</div>
</div>
</section>
<form class="form needs-validation" name="default" novalidate>
<form class="form needs-validation" name="defaultsForm" novalidate ng-submit="saveDefault($event)">
<section id="settings-terms" class="paper-settings-section">
<div class="paper-section-eyebrow">Default anonymization options</div>
<div class="paper-section-eyebrow">Terms to redact</div>
<p class="paper-section-copy">Pre-filled in every new anonymization. You can still edit the list per anonymization.</p>
<div class="form-group">
<label class="paper-field-label" for="terms">Terms to anonymize</label>
<label class="paper-field-label" for="terms">Default terms</label>
<textarea
class="form-control"
id="terms"
name="terms"
rows="4"
placeholder="Jane Smith&#10;MIT CSAIL&#10;jane.smith@example.org"
ng-model="terms"
ng-model-options="{ debounce: 250 }"
></textarea>
<small id="termsHelp" class="form-text text-muted">One term per line. Each term will be replaced by XXX.</small>
<div class="invalid-feedback" ng-show="anonymize.terms.$error.format">
<small id="termsHelp" class="form-text text-muted">
One term per line (regex allowed). Each match is replaced by
<code>{{site_options.ANONYMIZATION_MASK || 'XXX'}}-[N]</code>, or use <code>term=&gt;replacement</code> to pick your own.
</small>
<div class="invalid-feedback" ng-show="defaultsForm.terms.$error.format">
Terms are in an invalid format
</div>
</div>
</section>
<section id="settings-rendering" class="paper-settings-section">
<div class="paper-section-eyebrow">Rendering</div>
<div class="form-group">
<section id="settings-display" class="paper-settings-section">
<div class="paper-section-eyebrow">Display</div>
<p class="paper-section-copy">What reviewers can see inside an anonymized repository.</p>
<div class="form-group paper-check-list">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="link" name="link" ng-model="options.link" />
<label class="form-check-label" for="link">Keep links</label>
<small class="form-text text-muted">Keep or remove all the links.</small>
<small class="form-text text-muted">Unchecked, every link in text files is removed.</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="image" name="image" ng-model="options.image" />
<label class="form-check-label" for="image">Display images</label>
<small class="form-text text-muted">Images are not anonymized.</small>
<small class="form-text text-muted">Images are shown as is. They are not anonymized.</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="pdf" name="pdf" ng-model="options.pdf" />
<label class="form-check-label" for="pdf">Display PDFs</label>
<small class="form-text text-muted">PDFs are not anonymized.</small>
<small class="form-text text-muted">PDFs are shown as is. They are not anonymized.</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notebook" name="notebook" ng-model="options.notebook" />
<label class="form-check-label" for="notebook">Display Notebooks</label>
<label class="form-check-label" for="notebook">Display notebooks</label>
<small class="form-text text-muted">Render Jupyter notebooks instead of raw JSON.</small>
</div>
</div>
</section>
<section id="settings-features" class="paper-settings-section">
<div class="paper-section-eyebrow">Features</div>
<div class="form-group">
<div class="form-group paper-check-list">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="page" name="page" ng-model="options.page" />
<label class="form-check-label" for="page">Github page</label>
<small class="form-text text-muted">Enable anonymized GitHub pages. Currently only supported for pages defined in the same branch.</small>
<label class="form-check-label" for="page">GitHub Pages</label>
<small class="form-text text-muted">Serve an anonymized copy of the repository's GitHub Pages site. Only pages built from the anonymized branch are supported.</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="loc" name="loc" ng-model="options.loc" />
<label class="form-check-label" for="loc">Line of code</label>
<small class="form-text text-muted">Display the number of lines of code in the repository.</small>
<label class="form-check-label" for="loc">Lines of code</label>
<small class="form-text text-muted">Show the line count of the repository in the explorer.</small>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="update" name="update" ng-model="options.update" />
<label class="form-check-label" for="update">Auto update</label>
<small class="form-text text-muted">Automatically update the anonymized repository with the latest commit (once per hour maximum).</small>
<label class="form-check-label" for="update">Auto-update from GitHub</label>
<small class="form-text text-muted">Follow the branch and pull the latest commit automatically, at most once an hour.</small>
</div>
</div>
<div class="form-group">
<label class="paper-field-label" for="mode">Proxy mode</label>
<label class="paper-field-label" for="mode">Fetch mode</label>
<select class="form-control" id="mode" name="mode" ng-model="options.mode">
<option value="GitHubStream" selected>Stream</option>
<option value="GitHubDownload">Download</option>
<option value="GitHubStream" selected>Stream from GitHub on demand</option>
<option value="GitHubDownload">Download to Anonymous GitHub</option>
</select>
<small class="form-text text-muted">Stream mode requests content on the fly. This is the only option for repositories bigger than {{site_options.MAX_REPO_SIZE * 1024| humanFileSize}}. Download fetches to the server — faster and with more features.</small>
<small class="form-text text-muted">
Download is faster for reviewers and enables every feature. Streaming is the only option above
{{site_options.MAX_REPO_SIZE * 1024 | humanFileSize}}.
</small>
</div>
</section>
<section id="settings-expiration" class="paper-settings-section">
<div class="paper-section-eyebrow">Expiration</div>
<div class="form-group">
<label class="paper-field-label" for="expiration">Expiration strategy</label>
<label class="paper-field-label" for="expiration">When an anonymization expires</label>
<select class="form-control" id="expiration" name="expiration" ng-model="options.expirationMode">
<option value="redirect">Redirect to GitHub</option>
<option value="remove" selected>Remove anonymized repository</option>
<option value="redirect">Redirect visitors to the GitHub repository</option>
<option value="remove" selected>Remove the anonymized repository</option>
</select>
<small class="form-text text-muted">Define the expiration strategy for new repositories.</small>
<small class="form-text text-muted">
The expiration date itself is chosen per anonymization. Redirecting reveals the source repository, so pick it only once the review is over.
</small>
</div>
</section>
<div class="paper-settings-footer">
<div class="alert alert-danger" role="alert" ng-if="error" ng-bind="error"></div>
<div class="alert alert-success" role="alert" ng-if="message" ng-bind="message"></div>
<button id="save" type="submit" class="btn btn-ink" ng-click="saveDefault($event)">
Save defaults
</button>
<div class="paper-inline-alert paper-inline-alert-error" role="alert" ng-if="error"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <span ng-bind="error"></span></div>
<div class="paper-settings-actions">
<button id="save" type="submit" class="btn btn-ink" ng-disabled="saving">
<i class="fas fa-check mr-1" aria-hidden="true" ng-if="message"></i>{{ saving ? 'Saving…' : (message ? 'Saved' : 'Save defaults') }}
</button>
<span class="paper-settings-hint">Applies to new anonymizations only. Existing ones keep their settings.</span>
</div>
</div>
</form>
<section id="settings-account" class="paper-settings-section">
<div class="paper-section-eyebrow">Account</div>
<p class="form-text text-muted">
Deleting your account removes all your anonymized repositories,
gists, and pull requests, revokes the application's access to your
GitHub account, and erases your personal data (username, email,
access tokens) from our database. This cannot be undone.
</p>
<div class="alert alert-danger" role="alert" ng-if="deleteError" ng-bind="deleteError"></div>
<button type="button" class="btn btn-danger" ng-click="deleteAccount()" ng-disabled="deletingAccount">
{{ deletingAccount ? 'Deleting…' : 'Delete my account' }}
</button>
<div class="paper-account-card">
<img ng-src="{{user.photo}}" ng-if="user.photo" width="40" height="40" class="rounded-circle" alt="" />
<div class="paper-account-identity">
<div class="paper-account-name">@{{user.username}}</div>
<div class="paper-account-meta">Signed in with GitHub. Anonymous GitHub only reads your repositories.</div>
</div>
<a class="btn btn-outline-ink" href="/api/user/logout" target="_self">Sign out</a>
</div>
<div class="paper-danger-zone">
<div class="paper-danger-head">Delete account</div>
<p class="paper-danger-copy">
Removes all your anonymized repositories, gists, and pull requests,
revokes the application's access to your GitHub account, and erases
your personal data (username, email, access tokens). This cannot be undone.
</p>
<div class="paper-inline-alert paper-inline-alert-error" role="alert" ng-if="deleteError"><i class="fas fa-exclamation-circle" aria-hidden="true"></i> <span ng-bind="deleteError"></span></div>
<button type="button" class="btn btn-outline-danger" ng-click="deleteAccount()" ng-disabled="deletingAccount">
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>{{ deletingAccount ? 'Deleting…' : 'Delete my account' }}
</button>
</div>
</section>
</div>
</div>
+3 -3
View File
@@ -57,7 +57,7 @@
<div class="paper-error-actions">
<a class="btn btn-ink" ng-href="/anonymize/{{repoId}}"><i class="far fa-edit mr-1"></i> Edit anonymization</a>
<a class="btn" href="/faq"><i class="far fa-question-circle mr-1"></i> Read the FAQ</a>
<a class="btn" href="https://github.com/tdurieux/anonymous_github/issues/new" target="_blank" rel="noopener"><i class="fab fa-github mr-1"></i> Report an issue</a>
<a class="btn" href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener"><i class="fas fa-bug mr-1" aria-hidden="true"></i> Report a bug in Anonymous GitHub</a>
</div>
</div>
@@ -95,9 +95,9 @@
<p>Collaborate by implementing new features and fixing bugs. Help with new file formats or deployment is welcome.</p>
<span class="paper-support-cta">Open on GitHub <i class="fas fa-arrow-right"></i></span>
</a>
<a class="paper-support-card" href="https://github.com/tdurieux/anonymous_github/issues/new" target="_blank" rel="noopener">
<a class="paper-support-card" href="https://github.com/tdurieux/anonymous_github/issues/new?template=issue_report.yml" target="_blank" rel="noopener">
<div class="paper-support-eyebrow"><i class="far fa-comment-dots"></i> Feedback</div>
<p>Tell us about bugs and missing features. Your feedback shapes the project's priorities.</p>
<p>Tell us about bugs and missing features in Anonymous GitHub itself. Questions about an anonymized repository go to its authors.</p>
<span class="paper-support-cta">File an issue <i class="fas fa-arrow-right"></i></span>
</a>
<a class="paper-support-card" href="https://github.com/sponsors/tdurieux/" target="_blank" rel="noopener">
+117 -31
View File
@@ -319,6 +319,86 @@ angular
return msg;
};
})
// Shared quota loader. A total of 0 means unlimited: no percentage, no fill.
// `level` drives the bar colour: ink until 80%, amber to 95%, red above.
.factory("quotaService", [
"$http",
function ($http) {
function decorate(q) {
q = q || { used: 0, total: 0 };
q.unlimited = !q.total;
q.percent = q.unlimited ? 0 : Math.min(100, (q.used * 100) / q.total);
q.level = q.unlimited
? "unlimited"
: q.percent >= 95
? "danger"
: q.percent >= 80
? "warn"
: "ok";
return q;
}
return {
decorate: decorate,
load: function () {
return $http.get("/api/user/quota").then((res) => {
const quota = res.data || {};
quota.storage = decorate(quota.storage);
quota.file = decorate(quota.file);
quota.repository = decorate(quota.repository);
return quota;
});
},
};
},
])
// Highlights the link of the section currently in view inside a
// `.paper-settings-toc`-style navigation. Falls back to no-op without
// IntersectionObserver.
.directive("paperScrollspy", [
"$window",
function ($window) {
return {
restrict: "A",
link: function (scope, element) {
if (!$window.IntersectionObserver) return;
const links = Array.from(element[0].querySelectorAll('a[href^="#"]'));
const byId = {};
links.forEach((a) => {
byId[a.getAttribute("href").slice(1)] = a;
});
const visible = new Set();
function update() {
let current = null;
for (const id of Object.keys(byId)) {
if (visible.has(id)) {
current = id;
break;
}
}
links.forEach((a) => a.classList.toggle("active", a === byId[current]));
}
const observer = new $window.IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) visible.add(e.target.id);
else visible.delete(e.target.id);
});
update();
},
{ rootMargin: "-20% 0px -60% 0px", threshold: 0 }
);
// Sections may render after the toc; observe on the next tick.
$window.setTimeout(() => {
Object.keys(byId).forEach((id) => {
const target = $window.document.getElementById(id);
if (target) observer.observe(target);
});
}, 0);
scope.$on("$destroy", () => observer.disconnect());
},
};
},
])
.filter("diff", [
"$sce",
function ($sce) {
@@ -1189,7 +1269,9 @@ angular
"$scope",
"$http",
"$translate",
function ($scope, $http, $translate) {
"$timeout",
"quotaService",
function ($scope, $http, $translate, $timeout, quotaService) {
$scope.terms = "";
$scope.options = {
expirationMode: "remove",
@@ -1200,10 +1282,17 @@ angular
loc: true,
link: true,
};
$scope.saving = false;
$scope.message = null;
$scope.error = null;
quotaService.load().then((quota) => {
$scope.quota = quota;
}, console.error);
function getDefault() {
$http.get("/api/user/default").then((res) => {
const data = res.data;
const data = res.data || {};
if (data.terms) {
$scope.terms = data.terms.join("\n");
}
@@ -1212,20 +1301,36 @@ angular
}
getDefault();
$scope.saveDefault = () => {
let savedTimer = null;
$scope.saveDefault = ($event) => {
if ($event && $event.preventDefault) $event.preventDefault();
const params = {
terms: $scope.terms.trim().split("\n"),
terms: $scope.terms
.split("\n")
.map((t) => t.trim())
.filter((t) => t.length > 0),
options: $scope.options,
};
$scope.saving = true;
$scope.error = null;
$http.post("/api/user/default", params).then(
() => {
getDefault();
$scope.saving = false;
$scope.message = "Saved";
if (savedTimer) $timeout.cancel(savedTimer);
savedTimer = $timeout(() => {
$scope.message = null;
}, 2500);
},
(error) => {
$translate("ERRORS." + error.data.error).then((translation) => {
$scope.saving = false;
const code = error && error.data && error.data.error;
$translate("ERRORS." + code).then((translation) => {
$scope.error = translation;
}, console.error);
}, () => {
$scope.error = "Unable to save your defaults. Please try again.";
});
}
);
};
@@ -1379,7 +1484,8 @@ angular
"$location",
"$q",
"$window",
function ($scope, $http, $location, $q, $window) {
"quotaService",
function ($scope, $http, $location, $q, $window, quotaService) {
$scope.$on("$routeChangeStart", function () {
$('[data-toggle="tooltip"]').tooltip("dispose");
});
@@ -1493,30 +1599,10 @@ angular
true
);
// ---- Quota ---------------------------------------------------------
// A quota with total 0 is unlimited: no percentage, no fill. Colour is
// only introduced once a quota is nearly used up.
function decorateQuota(q) {
q.unlimited = !q.total;
q.percent = q.unlimited ? 0 : Math.min(100, (q.used * 100) / q.total);
q.level = q.unlimited
? "unlimited"
: q.percent >= 95
? "danger"
: q.percent >= 80
? "warn"
: "ok";
return q;
}
function getQuota() {
$http.get("/api/user/quota").then((res) => {
$scope.quota = res.data;
decorateQuota($scope.quota.storage);
decorateQuota($scope.quota.file);
decorateQuota($scope.quota.repository);
}, console.error);
}
getQuota();
// ---- Quota (shared with the settings page via quotaService) --------
quotaService.load().then((quota) => {
$scope.quota = quota;
}, console.error);
// ---- Items ---------------------------------------------------------
// Fields shared by repositories, pull requests and gists. Records that
+2 -2
View File
File diff suppressed because one or more lines are too long
+82 -4
View File
@@ -180,21 +180,99 @@ app.get(/^\/(script|css)\/(.+)\.([a-f0-9]{10})\.(min\.\w+|\w+)$/, (req, res, nex
res.sendFile(filePath);
});
app.get("/api/user", (req, res) => res.json({ username: "tdurieux", photo: "", isAdmin: false }));
app.get("/api/options", (req, res) => res.json({}));
const conferences = [
{
conferenceID: "FSE25",
name: "Foundations of Software Engineering 2025",
url: "https://conf.researchr.org/home/fse-2025",
startDate: ago(400),
endDate: ahead(30),
status: "ready",
options: opts({ page: true }),
plan: { planID: "premium_conference", name: "Premium", pricePerRepository: 0.5 },
price: 12.5,
nbRepositories: 2,
repositories: repositories.slice(0, 4).map((r) => Object.assign({}, r, { addDate: ago(60) })),
},
{
conferenceID: "ICSE26-AE",
name: "ICSE 2026 Artifact Evaluation",
url: "https://conf.researchr.org/home/icse-2026",
startDate: ahead(10),
endDate: ahead(120),
status: "ready",
options: opts(),
plan: { planID: "free_conference", name: "Free", pricePerRepository: 0 },
price: 0,
nbRepositories: 0,
repositories: [],
},
{
conferenceID: "FSE23",
name: "Foundations of Software Engineering 2023",
url: "",
startDate: ago(1200),
endDate: ago(900),
status: "expired",
options: opts(),
plan: { planID: "free_conference", name: "Free", pricePerRepository: 0 },
price: 0,
nbRepositories: 3,
repositories: [],
},
];
const plans = [
{ id: "free_conference", name: "Free", pricePerRepo: 0, storagePerRepo: -1, description: "<li><strong>Quota is deducted from user account</strong></li><li>No download</li><li>Conference dashboard</li>" },
{ id: "premium_conference", name: "Premium", pricePerRepo: 0.5, storagePerRepo: 500 * 8 * 1024, description: "<li>500 MB per repository</li><li>Repository download</li><li>Conference dashboard</li>" },
{ id: "unlimited_conference", name: "Unlimited", pricePerRepo: 3, storagePerRepo: 0, description: "<li><strong>Unlimited</strong> repository size</li><li>Repository download</li><li>Conference dashboard</li>" },
];
const stat = { nbRepositories: 41230, nbUsers: 9870, nbPageViews: 3120000, nbPullRequests: 1480 };
const history = Array.from({ length: 60 }, (_, i) => ({
date: ago(59 - i),
nbRepositories: 40000 + i * 20,
nbUsers: 9500 + i * 6,
nbPageViews: 3000000 + i * 2000,
nbPullRequests: 1400 + i,
}));
// ANON=1 serves the signed-out experience (landing page, FAQ) instead.
app.get("/api/user", (req, res) =>
process.env.ANON
? res.status(401).json({ error: "not_connected" })
: res.json({ username: "tdurieux", photo: "https://avatars.githubusercontent.com/u/5577568?v=4", isAdmin: false })
);
app.get("/api/options", (req, res) => res.json({ MAX_REPO_SIZE: 8 * 1024, ANONYMIZATION_MASK: "XXXX" }));
app.get("/api/message", (req, res) => res.status(404).end());
app.get("/api/user/quota", (req, res) => res.json(quota));
app.get("/api/user/anonymized_repositories", (req, res) => res.json(repositories));
app.get("/api/user/anonymized_pull_requests", (req, res) => res.json(pullRequests));
app.get("/api/user/anonymized_gists", (req, res) => res.json(gists));
app.get("/api/user/default", (req, res) => res.json({}));
app.get("/api/user/default", (req, res) =>
res.json({ terms: ["Jane Smith", "MIT CSAIL"], options: { link: true, image: true, pdf: true, notebook: true, loc: true, page: false, update: false, mode: "GitHubStream", expirationMode: "remove" } })
);
app.post("/api/user/default", (req, res) => res.json({}));
app.get("/api/conferences/plans", (req, res) => res.json(plans));
app.get("/api/conferences/", (req, res) => res.json(conferences));
app.get("/api/conferences/:id", (req, res) => {
const c = conferences.find((x) => x.conferenceID === req.params.id);
return c ? res.json(c) : res.status(404).json({ error: "conf_not_found" });
});
app.get("/api/repo/:id", (req, res) => {
const r = repositories.find((x) => x.repoId === req.params.id) || repositories[0];
res.json(r);
});
app.get("/api/stat", (req, res) => res.json(stat));
app.get("/api/stat/history", (req, res) => res.json(history));
app.all("/api/{*rest}", (req, res) => res.status(404).json({ error: "not mocked: " + req.path }));
app.use((req, res, next) => {
res.setHeader("Cache-Control", "no-store, max-age=0");
next();
});
app.use(express.static(PUBLIC_DIR, { etag: false, cacheControl: false }));
// index: false so "/" falls through to the placeholder-filled index.html below.
app.use(express.static(PUBLIC_DIR, { etag: false, cacheControl: false, index: false }));
app.get("/{*rest}", (req, res) => res.type("html").send(indexHtml()));
app.listen(PORT, () => {
+2 -1
View File
@@ -135,7 +135,8 @@ describe("frontend production regressions", function () {
});
it("translates profile save failures", async function () {
const h = harness(); expect(h.defs.profileController).to.include("$translate");
h.defs.profileController.at(-1)(h.scope, h.http, key => Promise.resolve(key));
const timeout = Object.assign(() => 0, { cancel() {} });
h.defs.profileController.at(-1)(h.scope, h.http, key => Promise.resolve(key), timeout, { load: () => Promise.resolve({}) });
h.scope.saveDefault(); h.requests.at(-1).reject({ data: { error: "not_connected" } }); await h.flush();
expect(h.scope.error).to.equal("ERRORS.not_connected");
});