mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 08:17:26 +01:00
Most authors use the service for one paper a few times a year, so a subscription does not fit. Add cheap one-time options (Paper pack, Extra repository, Live updates, Extended expiration, Never expire), cap free dated expiration at 5 months, keep cheaper subscriptions for heavy users, and make every new conference a paid review package in three sizes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
23 KiB
HTML
184 lines
23 KiB
HTML
<div class="container paper-page user-settings">
|
||
<header class="settings-title"><h1>Settings</h1><div class="settings-header-actions"><span>@{{ user?.username }}</span></div></header>
|
||
<nav class="settings-nav" aria-label="Settings">
|
||
<button type="button" v-for="tab in [{id:'defaults',label:'Anonymization defaults'},{id:'billing',label:'Hosting & billing'},{id:'account',label:'Account'}]" :aria-current="settingsTab === tab.id ? 'page' : null" @click="settingsTab = tab.id">{{ tab.label }}</button>
|
||
</nav>
|
||
<div v-show="settingsTab === 'defaults'">
|
||
<p class="settings-scope">For new anonymizations only. Existing repositories keep their settings.</p>
|
||
<form class="form needs-validation" name="defaultsForm" novalidate v-form="viewState" @submit.prevent="submitForm($event, () => { saveDefault($event) })">
|
||
<section class="settings-row"><div><label class="settings-label" for="terms">Terms to redact</label><p class="settings-help">One term per line.</p></div><div><textarea class="form-control" id="terms" name="terms" :rows="Math.min(14, Math.max(3, String(terms || "").split("\n").length + 1))" placeholder="Jane Smith
|
||
MIT CSAIL
|
||
jane.smith@example.org" v-field="{ state: viewState, set: value => { terms = value }, value: terms, form: "defaultsForm", options: { debounce: 250 } }"></textarea>
|
||
<details class="settings-syntax"><summary>Regex & replacement syntax</summary><p>Matches become <code>{{ site_options?.ANONYMIZATION_MASK || 'XXX' }}-[N]</code>. Use <code>term=>replacement</code> for a custom replacement. Regex is supported.</p></details>
|
||
<div class="invalid-feedback" v-show="defaultsForm?.terms?.errors?.format">Terms are in an invalid format.</div>
|
||
</div></section>
|
||
<section class="settings-row"><h2>Reviewer display</h2><div><div class="settings-checks"><label class="settings-check"><input class="form-check-input" type="checkbox" id="link" name="link" v-field="{ state: viewState, set: value => { options.link = value }, value: options?.link, form: "defaultsForm", options: {} }">Keep links</label><label class="settings-check"><input class="form-check-input" type="checkbox" id="notebook" name="notebook" v-field="{ state: viewState, set: value => { options.notebook = value }, value: options?.notebook, form: "defaultsForm", options: {} }">Render notebooks</label><label class="settings-check"><input class="form-check-input" type="checkbox" id="image" name="image" v-field="{ state: viewState, set: value => { options.image = value }, value: options?.image, form: "defaultsForm", options: {} }">Show images</label><label class="settings-check"><input class="form-check-input" type="checkbox" id="pdf" name="pdf" v-field="{ state: viewState, set: value => { options.pdf = value }, value: options?.pdf, form: "defaultsForm", options: {} }">Show PDFs</label><label class="settings-check"><input class="form-check-input" type="checkbox" id="loc" name="loc" v-field="{ state: viewState, set: value => { options.loc = value }, value: options?.loc, form: "defaultsForm", options: {} }">Show line counts</label><label class="settings-check"><input class="form-check-input" type="checkbox" id="page" name="page" v-field="{ state: viewState, set: value => { options.page = value }, value: options?.page, form: "defaultsForm", options: {} }">GitHub Pages</label></div><p class="settings-help">Images and PDFs are not anonymized.</p><p class="settings-help">GitHub Pages must use the anonymized branch.</p></div></section>
|
||
<section class="settings-row"><h2>Source updates</h2><div><label class="settings-check"><input class="form-check-input" type="checkbox" id="update" name="update" v-field="{ state: viewState, set: value => { options.update = value }, value: options?.update, form: "defaultsForm", options: {} }">Auto-update from GitHub</label><p class="settings-help">Latest commit, at most once an hour.</p></div></section>
|
||
<section class="settings-row"><label class="settings-label" for="expiration">On expiration</label><div><select class="form-control" id="expiration" name="expiration" v-field="{ state: viewState, set: value => { options.expirationMode = value }, value: options?.expirationMode, form: "defaultsForm", options: {} }">
|
||
<option value="redirect">Redirect visitors to the GitHub repository</option>
|
||
<option value="remove" selected>Remove the anonymized repository</option>
|
||
</select><p class="settings-help">{{ options.expirationMode === 'redirect' ? 'Redirecting reveals the original repository. Choose the date per anonymization.' : 'Set the expiration date for each repository.' }}</p></div></section>
|
||
<footer class="settings-save">
|
||
<div class="settings-save-status draft-status" role="status"><span v-if="draftRestored">Your unsaved draft was restored.</span><span v-else-if="unsavedChanges">Unsaved changes{{ draftSaved ? ' · Draft saved in this tab' : '' }}</span><span v-else>{{ message ? 'Defaults saved' : 'All changes saved' }}</span></div>
|
||
<button type="button" class="draft-discard" v-if="draftRestored || unsavedChanges" @click="discardDraft()">Discard changes</button>
|
||
<button id="save" type="submit" class="btn btn-ink" :disabled="saving">{{ saving ? 'Saving…' : 'Save defaults' }}</button>
|
||
<div class="settings-save-error" role="alert" v-if="error">{{ error }}</div>
|
||
</footer>
|
||
</form>
|
||
</div>
|
||
<section id="settings-billing" v-show="settingsTab === 'billing'" :aria-busy="billingLoading || billingBusy">
|
||
<p class="settings-scope">Your plan and usage. Reviewer access is always free.</p>
|
||
<p v-if="billingLoading" role="status">Loading billing…</p>
|
||
<div v-if="billingError" class="settings-notice" role="alert">{{ billingError }} <button type="button" class="btn btn-outline-ink" @click="loadBilling()" :disabled="billingBusy">Try again</button></div>
|
||
<p v-if="billingNotice" class="settings-notice" role="status">{{ billingNotice }}</p>
|
||
<div v-if="billingPending" class="settings-notice" role="status"><strong>Confirming your payment</strong><p>Your allowance will update after confirmation. Please do not pay again.</p><button type="button" class="btn btn-outline-ink" @click="refreshBilling()" :disabled="billingBusy">Check payment status</button></div>
|
||
<section class="billing-summary">
|
||
<div class="billing-summary-head">
|
||
<div>
|
||
<p class="billing-eyebrow">Current plan</p>
|
||
<h2 class="billing-plan-name">{{ profileAuthorGrant() ? profileGrantName(profileAuthorGrant()) : (billing?.enabled ? 'Free' : 'Account allowance') }}</h2>
|
||
<p class="billing-plan-meta" v-if="profileAuthorGrant()">{{ profileAuthorGrant().cancelAtPeriodEnd ? 'Renewal cancelled · access until ' : 'Renews ' }}{{ billingDate(profileAuthorGrant().until) }}<template v-if="profileAuthorPurchase()?.amount != null"> · {{ billingPrice(profileAuthorPurchase()) }}</template></p>
|
||
<p class="billing-plan-meta" v-else-if="billing?.allowance">{{ billing.allowance.repositories }} repositories · {{ billingBytes(billing.allowance.storage) }} · links up to {{ billing.freeExpirationMonths || 5 }} months</p>
|
||
</div>
|
||
<div class="billing-summary-actions" v-if="profileAuthorGrant() && billing?.portal">
|
||
<button type="button" class="btn btn-outline-ink" @click="billingPortal()" :disabled="billingBusy">{{ profileAuthorGrant().cancelAtPeriodEnd ? 'Resume renewal' : 'Change or cancel plan' }}</button>
|
||
</div>
|
||
|
||
</div>
|
||
<div id="settings-quota"><quota-usage :quota="quota" :fmt="fmt" v-if="quota"></quota-usage><p v-else role="status">Loading usage…</p></div>
|
||
<p class="settings-notice" v-if="quota?.repository && !quota.repository.unlimited && quota.repository.used >= quota.repository.total">Your free repositories are used up. Add the next one for {{ profileSlotPlan() ? billingPrice(profileSlotPlan()) : 'a one-time payment' }} when you create it, or <a href="/dashboard">remove work you no longer need</a>.</p>
|
||
<p class="settings-notice" v-if="profileAuthorGrant()?.status === 'past_due'">A subscription payment is overdue. Update your payment details in Manage billing and invoices.</p>
|
||
<p class="settings-notice" v-if="profilePastSubscription()">Your previous subscription has ended. Never-expiring repositories stay online for a 90-day grace period. <a href="/dashboard">Review expiration dates</a>.</p>
|
||
<p class="billing-footnote">Coauthored, paid and conference repositories aren't counted here.</p>
|
||
</section>
|
||
|
||
<section class="billing-section" v-if="billing?.enabled && !profileAuthorGrant() && (profilePackPlan() || profileAuthorPlans().length)" aria-labelledby="settings-plan-comparison">
|
||
<h2 class="billing-section-title" id="settings-plan-comparison" tabindex="-1">Get more</h2>
|
||
<div class="billing-offers" v-if="!(showRepositoryPass && profileAddon() && !billingPending)">
|
||
<article class="billing-offer is-recommended" v-if="profilePackPlan()">
|
||
<span class="billing-offer-pill">Recommended</span>
|
||
<div class="billing-offer-head"><p class="billing-eyebrow">For one paper</p></div>
|
||
<h3>Paper pack</h3>
|
||
<p class="billing-offer-price"><strong>{{ billingAmount(profilePackPlan()) }}</strong> once</p>
|
||
<ul class="billing-offer-list"><li>One more repository, outside your free ones</li><li>Its own {{ billingBytes(profileSlotPlan()?.storage || profilePassPlan()?.storage) }} of storage</li><li>Automatic updates for 180 days</li><li>Online for up to a year</li><li>No subscription</li></ul>
|
||
<button type="button" class="btn btn-ink" @click="openProfileAddon('repo_pack')" :disabled="billingPending || billing.checkoutEnabled === false">Buy a Paper pack</button>
|
||
</article>
|
||
<article class="billing-offer" v-if="profileAuthorPlans().length">
|
||
<div class="billing-offer-head"><p class="billing-eyebrow">For many papers</p><div class="billing-toggle" role="radiogroup" aria-label="Billing frequency"><button type="button" role="radio" v-for="plan in profileAuthorPlansByValue()" :key="plan.id" :aria-checked="profileSelectedPlan()?.id === plan.id ? 'true' : 'false'" :class="{ 'is-on': profileSelectedPlan()?.id === plan.id }" @click="profilePlan = plan.id">{{ plan.interval === 'year' ? 'Yearly' : 'Monthly' }}<span v-if="plan.interval === 'year' && profileAnnualSavings()" class="billing-toggle-save">−{{ profileAnnualSavings() }}%</span></button></div></div>
|
||
<h3>Author subscription</h3>
|
||
<p class="billing-offer-price"><strong>{{ billingAmount(profileSelectedPlan()) }}</strong> per {{ profileSelectedPlan()?.interval === 'year' ? 'year' : 'month' }}</p>
|
||
<ul class="billing-offer-list"><li>{{ profileSelectedPlan()?.repositories }} repositories, {{ billingBytes(profileSelectedPlan()?.storage) }}</li><li>Automatic updates</li><li>Links that never expire</li><li>Coauthors included</li><li>Cancel renewal anytime</li></ul>
|
||
<button type="button" class="btn btn-outline-ink" :disabled="billingBusy || billing.checkoutEnabled === false" @click="checkout(profileSelectedPlan().id)">{{ billingBusy ? 'Please wait…' : 'Subscribe' }}</button>
|
||
</article>
|
||
</div>
|
||
|
||
<div class="billing-addons" v-if="profileSingleAddons().length && !(showRepositoryPass && profileAddon() && !billingPending)">
|
||
<div class="billing-addons-head"><h3 class="billing-subtitle">Or pay for one thing</h3><span>One-time</span></div>
|
||
<ul class="billing-addon-rows"><li v-for="plan in profileSingleAddons()" :key="plan.id"><div><strong>{{ plan.name }}</strong><span>{{ profileAddonBlurb(plan.id) }}</span></div><span class="billing-addon-price">{{ billingAmount(plan) }}</span><button type="button" class="settings-text-button" @click="openProfileAddon(plan.id)" :disabled="billingPending || billing.checkoutEnabled === false">Buy</button></li></ul>
|
||
</div>
|
||
|
||
<div class="billing-buy" v-if="showRepositoryPass && profileAddon() && !billingPending" id="settings-addons-title" tabindex="-1">
|
||
<div class="billing-buy-info">
|
||
<button type="button" class="btn btn-outline-ink billing-back" @click="showRepositoryPass = false">← All options</button>
|
||
<h3>{{ profileAddon().name }}</h3>
|
||
<p class="billing-offer-price"><strong>{{ billingAmount(profileAddon()) }}</strong> once</p>
|
||
<ul class="billing-offer-list"><li v-for="item in profileAddonFeatures(profileAddon().id)" :key="item">{{ item }}</li></ul>
|
||
</div>
|
||
<div class="billing-buy-form">
|
||
<template v-if="profileRepositories().length">
|
||
<label class="paper-field-label" for="billing-repository">Which repository?</label>
|
||
<select id="billing-repository" class="form-control" v-model="billingRepository"><option value="" disabled>Choose a repository</option><option v-for="repo in profileRepositories()" :key="repo.repoId" :value="repo.repoId">{{ repo.repoId }} · {{ profileRepositoryExpiry(repo) }}</option></select>
|
||
<button type="button" class="btn btn-ink" :disabled="billingBusy || !billingRepository || billing.checkoutEnabled === false" @click="checkout(profileAddon().id, billingRepository)">{{ billingBusy ? 'Please wait…' : 'Continue to checkout' }}</button>
|
||
<p class="billing-footnote">One-time payment. Tax shown at checkout.</p>
|
||
</template>
|
||
<p class="billing-buy-desc" v-else>You have no repositories yet. <a href="/anonymize">Create one</a> first.</p>
|
||
<p class="billing-footnote" v-if="['repo_slot', 'repo_pack'].includes(profileAddon().id)">Adding a new repository? <a href="/anonymize">Start creating it</a>; the form offers this once your free repositories are in use.</p>
|
||
</div>
|
||
</div>
|
||
<details class="billing-compare"><summary>Compare all features</summary><div class="billing-compare-card">
|
||
<table class="settings-comparison"><caption class="sr-only">Free, Paper pack and Author hosting</caption><thead><tr><th scope="col"><span class="sr-only">Feature</span></th><th scope="col">Free<small>€0</small></th><th scope="col" class="is-recommended" v-if="profilePackPlan()">Paper pack<small>{{ billingPrice(profilePackPlan()) }}</small></th><th scope="col" v-if="profileAuthorPlans().length">Author<small>{{ billingPrice(profileSelectedPlan()) }}</small></th></tr></thead><tbody>
|
||
<tr><th scope="row">Repositories</th><td>{{ billing.allowance?.repositories }}</td><td class="is-recommended" v-if="profilePackPlan()">+1</td><td v-if="profileAuthorPlans().length">{{ profileSelectedPlan()?.repositories }}</td></tr>
|
||
<tr><th scope="row">Source storage</th><td>{{ billingBytes(billing.allowance?.storage) }} total</td><td class="is-recommended" v-if="profilePackPlan()">{{ billingBytes(profileSlotPlan()?.storage || profilePassPlan()?.storage) }} for it</td><td v-if="profileAuthorPlans().length">{{ billingBytes(profileSelectedPlan()?.storage) }} total</td></tr>
|
||
<tr><th scope="row">Latest expiration</th><td>{{ billing.freeExpirationMonths || 5 }} months ahead</td><td class="is-recommended" v-if="profilePackPlan()">1 year after purchase</td><td v-if="profileAuthorPlans().length">Never</td></tr>
|
||
<tr><th scope="row">Automatic updates</th><td>No</td><td class="is-recommended" v-if="profilePackPlan()">180 days</td><td v-if="profileAuthorPlans().length">Yes</td></tr>
|
||
<tr><th scope="row">Renews</th><td>No</td><td class="is-recommended" v-if="profilePackPlan()">No</td><td v-if="profileAuthorPlans().length">Until cancelled</td></tr>
|
||
<tr><th scope="row">Reviewer access</th><td>Free</td><td class="is-recommended" v-if="profilePackPlan()">Free</td><td v-if="profileAuthorPlans().length">Free</td></tr>
|
||
</tbody></table></div>
|
||
</details>
|
||
<p class="billing-footnote" v-if="billing.checkoutEnabled === false">New purchases are not available right now. Existing billing can still be managed below.</p>
|
||
</section>
|
||
|
||
|
||
<section class="billing-section" v-if="billing?.enabled">
|
||
<div class="billing-section-head"><h2 class="billing-section-title">Purchases</h2><button type="button" class="settings-text-button" v-if="!billingPending" @click="refreshBilling()" :disabled="billingBusy">Refresh payment status</button></div>
|
||
<p class="billing-empty" v-if="!(billing.purchases || []).length">No purchases yet.</p>
|
||
<div class="billing-history-head" v-if="(billing.purchases || []).length" aria-hidden="true"><span>Purchase</span><span>Period</span><span>Amount</span><span>Status</span></div>
|
||
<ul class="billing-history" v-if="(billing.purchases || []).length">
|
||
<li class="billing-history-row" v-for="purchase in profilePurchases(true).concat(profilePurchases(false))" :key="purchase.id" :class="{ 'is-ended': !['active', 'overdue'].includes(purchase.status) }">
|
||
<div class="billing-history-main">
|
||
<strong>{{ profileGrantName(purchase) }}<template v-if="purchase.repoId"> · <a :href="'/anonymize/' + encodeURIComponent(purchase.repoId)">{{ purchase.repoId }}</a></template><template v-else-if="purchase.conference"> · <a :href="'/conference/' + encodeURIComponent(purchase.conference)">{{ purchase.conference }}</a></template></strong>
|
||
<span>{{ [purchase.purchasedAt ? 'Bought ' + billingDate(purchase.purchasedAt) : '', purchase.parts.length ? profilePackSummary(purchase) : ['refunded', 'disputed'].includes(purchase.status) ? 'Payment ' + purchase.status + ', so what it paid for has ended' : purchase.recurring ? 'Account subscription' : ''].filter(Boolean).join(' · ') }}</span>
|
||
</div>
|
||
<div class="billing-history-term">{{ profilePurchaseTerm(purchase) }}</div>
|
||
<div class="billing-history-paid">{{ purchase.amount != null ? billingPrice(purchase) : '' }}</div>
|
||
<div class="billing-history-status"><span class="settings-status" :class="'is-' + purchase.status">{{ profilePurchaseStatus(purchase) }}</span></div>
|
||
</li>
|
||
</ul>
|
||
<div class="billing-manage">
|
||
<div><strong>Invoices and payment details</strong><p v-if="billing.portal">Download receipts, update your card or change renewal in Stripe.</p><p v-else>Invoices appear here after your first purchase.</p>
|
||
<p>Need a refund? <a href="mailto:tdurieux@anonymous.4open.science">Email the maintainer</a> with the payment date, or read the <a href="/faq#faq-plans">refund policy</a>.</p></div>
|
||
<button type="button" class="btn btn-outline-ink" v-if="billing.portal" @click="billingPortal()" :disabled="billingBusy">Manage billing and invoices</button>
|
||
</div>
|
||
</section>
|
||
|
||
<p class="billing-conference">Organizing a conference? <template v-if="profileConferencePlan()">Review packages start at {{ billingAmount(profileConferencePlan()) }} once for up to {{ profileConferencePlan().repositories }} submissions, and authors pay nothing.</template><template v-else>One payment hosts every submission, and authors pay nothing.</template> <a href="/conference/new">Create a conference</a></p>
|
||
</section>
|
||
<section id="settings-account" class="settings-account-panel" v-show="settingsTab === 'account'">
|
||
<p class="settings-scope">Your identity, repository access, and account.</p>
|
||
<div class="paper-account-card">
|
||
<img width="40" height="40" class="rounded-circle" alt="" v-if="user?.photo" :src="safeUrl((user?.photo))">
|
||
<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>
|
||
|
||
<form class="paper-account-email" @submit.prevent="saveAccountEmail()">
|
||
<label class="paper-field-label" for="account-email">Email address</label>
|
||
<p id="account-email-help" class="paper-section-copy">Used for account alerts. Your address is never shown on anonymous pages.</p>
|
||
<input id="account-email" class="form-control" type="email" autocomplete="email" maxlength="254" required v-model="accountEmail" aria-describedby="account-email-help" :disabled="emailLoading || emailSaving || !emailCSRFReady" @input="emailSaved = false" />
|
||
<p role="status" v-if="emailLoading">Loading your email address…</p>
|
||
<p class="paper-inline-alert paper-inline-alert-error" role="alert" v-if="emailError">{{ emailError }}</p>
|
||
<button class="btn btn-outline-ink mt-2" type="button" v-if="!emailCSRFReady && !emailLoading" @click="loadAccountEmail()">Try again</button>
|
||
<button class="btn btn-ink mt-2" type="submit" :disabled="emailLoading || emailSaving || !emailCSRFReady">{{ emailSaving ? 'Saving…' : 'Save email address' }}</button>
|
||
<p role="status" v-if="emailSaved">Email address saved.</p>
|
||
</form>
|
||
|
||
<a class="paper-settings-connection-link" href="/connections">
|
||
<i class="fab fa-github" aria-hidden="true"></i>
|
||
<span>GitHub connections<small>Manage repository access and legacy OAuth.</small></span>
|
||
<span aria-hidden="true">→</span>
|
||
</a>
|
||
|
||
<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" v-if="deleteError"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-info"></use></svg> <span v-text="deleteError"></span></div>
|
||
<button type="button" class="btn btn-outline-danger" @click="confirmDeleteAccount = true" :disabled="deletingAccount">
|
||
<svg class="ui-icon mr-1" aria-hidden="true"><use href="#ui-icon-trash"></use></svg>{{ deletingAccount ? 'Deleting…' : 'Delete my account' }}
|
||
</button>
|
||
<div class="settings-delete-confirm" v-if="confirmDeleteAccount">
|
||
<label for="delete-username">Type {{ user?.username }} to confirm</label>
|
||
<input id="delete-username" class="form-control" v-model="deleteUsername" autocomplete="off">
|
||
<div class="settings-actions"><button type="button" class="btn btn-outline-danger" @click="deleteAccount()" :disabled="deletingAccount || deleteUsername !== user?.username">{{ deletingAccount ? 'Deleting…' : 'Delete permanently' }}</button><button type="button" class="btn btn-outline-ink" @click="confirmDeleteAccount = false; deleteUsername = ''" :disabled="deletingAccount">Cancel</button></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|