Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
754 lines
39 KiB
HTML
754 lines
39 KiB
HTML
{{define "title"}}{{ tr "Settings" }} · {{ tr "Volumen admin" }}{{end}}
|
|
{{define "content"}}
|
|
<div class="page-head">
|
|
<div class="page-head__text">
|
|
<h1>{{ tr "Settings" }}</h1>
|
|
<p class="page-head__lede">{{ tr "Manage your account and users" }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{{if .Notice}}
|
|
<div class="status status--ok" role="status">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#check"></use></svg>
|
|
<div>{{ .Notice }}</div>
|
|
</div>
|
|
{{end}}
|
|
{{if .Error}}
|
|
<div class="status status--error" role="alert">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
|
<div>{{ .Error }}</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
<div class="settings">
|
|
<nav class="settings__nav" aria-label="{{ tr "Settings" }}">
|
|
<a href="#account" class="settings__link">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#user"></use></svg>
|
|
{{ tr "Account" }}
|
|
</a>
|
|
{{if .IsAdmin}}
|
|
<a href="#users" class="settings__link">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#users"></use></svg>
|
|
{{ tr "Users" }}
|
|
</a>
|
|
<a href="#templates" class="settings__link">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#file-text"></use></svg>
|
|
{{ tr "Templates" }}
|
|
</a>
|
|
<a href="#backup" class="settings__link">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#download"></use></svg>
|
|
{{ tr "Backup" }}
|
|
</a>
|
|
<a href="#version" class="settings__link">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#repeat"></use></svg>
|
|
{{ tr "Version" }}
|
|
</a>
|
|
<a href="#webhooks" class="settings__link">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#compare"></use></svg>
|
|
{{ tr "Webhooks" }}
|
|
</a>
|
|
<a href="#tokens" class="settings__link">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#key"></use></svg>
|
|
{{ tr "API tokens" }}
|
|
</a>
|
|
{{end}}
|
|
</nav>
|
|
|
|
<div class="settings__panels">
|
|
<section id="account" class="card">
|
|
<div class="card__head">
|
|
<h2>{{ tr "Account" }}</h2>
|
|
<p class="card__note">{{ tr "Signed in as" }} <strong>{{ .CurrentUser }}</strong> · {{ tr .CurrentRole }}</p>
|
|
</div>
|
|
|
|
<div class="setting">
|
|
<span class="card__icon" aria-hidden="true">
|
|
<svg><use href="/admin/assets/graphis.svg#user"></use></svg>
|
|
</span>
|
|
<div class="setting__body">
|
|
<h3 class="setting__title">{{ tr "Profile photo" }}</h3>
|
|
<p class="setting__desc">{{ tr "WebP, AVIF or SVG, max 10 MB. Shown in the sidebar." }}</p>
|
|
<form method="post" action="/admin/settings/photo" enctype="multipart/form-data">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="photo-row">
|
|
{{if .CurrentUserRecord.Photo}}
|
|
<img src="{{ .CurrentUserRecord.Photo }}" alt="" class="photo-row__preview">
|
|
{{else}}
|
|
<span class="photo-row__preview photo-row__placeholder">{{ .UserInitial }}</span>
|
|
{{end}}
|
|
<div class="photo-row__fields">
|
|
<input type="file" name="photo" accept="image/webp,image/avif,image/svg+xml" required>
|
|
<div class="photo-row__buttons">
|
|
<button type="submit" class="btn btn--primary btn--sm">{{ tr "Upload" }}</button>
|
|
{{if .CurrentUserRecord.Photo}}
|
|
<button type="submit" form="remove-photo-form" class="btn btn--danger btn--sm">{{ tr "Remove" }}</button>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
{{if .CurrentUserRecord.Photo}}
|
|
<form id="remove-photo-form" method="post" action="/admin/settings/photo/remove" hidden>
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting">
|
|
<span class="card__icon" aria-hidden="true">
|
|
<svg><use href="/admin/assets/graphis.svg#target"></use></svg>
|
|
</span>
|
|
<div class="setting__body">
|
|
<h3 class="setting__title">{{ tr "Language" }}</h3>
|
|
<p class="setting__desc">{{ tr "The interface language of your account. The login screen follows your last choice." }}</p>
|
|
<form method="post" action="/admin/settings/language" class="pill-group">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<button type="submit" name="language" value="en" class="chip" aria-pressed="{{if eq .Lang "en"}}true{{else}}false{{end}}" lang="en">English</button>
|
|
<button type="submit" name="language" value="cs" class="chip" aria-pressed="{{if eq .Lang "cs"}}true{{else}}false{{end}}" lang="cs">Čeština</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting">
|
|
<span class="card__icon" aria-hidden="true">
|
|
<svg><use href="/admin/assets/graphis.svg#droplet"></use></svg>
|
|
</span>
|
|
<div class="setting__body">
|
|
<h3 class="setting__title">{{ tr "Colour scheme" }}</h3>
|
|
<p class="setting__desc">{{ tr "The colour scheme of your account. The login screen follows your last choice." }}</p>
|
|
<form method="post" action="/admin/settings/theme" class="pill-group">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<button type="submit" name="theme" value="viridis" class="chip" aria-pressed="{{if eq .Theme "viridis"}}true{{else}}false{{end}}">Viridis</button>
|
|
<button type="submit" name="theme" value="plasma" class="chip" aria-pressed="{{if eq .Theme "plasma"}}true{{else}}false{{end}}">Plasma</button>
|
|
<button type="submit" name="theme" value="magma" class="chip" aria-pressed="{{if eq .Theme "magma"}}true{{else}}false{{end}}">Magma</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting">
|
|
<span class="card__icon" aria-hidden="true">
|
|
<svg><use href="/admin/assets/graphis.svg#type"></use></svg>
|
|
</span>
|
|
<div class="setting__body">
|
|
<h3 class="setting__title">{{ tr "Identity" }}</h3>
|
|
<p class="setting__desc">{{ tr "Display name pre-fills the author field. Fediverse handle pre-fills the creator, and the ORCID pre-fills the author identifier of new publications." }}</p>
|
|
<form method="post" action="/admin/settings/name" class="setting__row">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="field">
|
|
<label class="field__label" for="name">{{ tr "Display name" }}</label>
|
|
<input id="name" class="control" type="text" name="name" value="{{ .CurrentUserRecord.Name }}"
|
|
placeholder="{{ tr "Your real name" }}">
|
|
</div>
|
|
<button type="submit" class="btn">{{ tr "Save name" }}</button>
|
|
</form>
|
|
<form method="post" action="/admin/settings/fediverse" class="setting__row gap-top">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="field">
|
|
<label class="field__label" for="fediverse_creator">{{ tr "Fediverse handle" }}</label>
|
|
<input id="fediverse_creator" class="control" type="text" name="fediverse_creator"
|
|
value="{{ .CurrentUserRecord.FediverseCreator }}" placeholder="{{ tr "@user@instance.tld" }}">
|
|
</div>
|
|
<button type="submit" class="btn">{{ tr "Save handle" }}</button>
|
|
</form>
|
|
<form method="post" action="/admin/settings/orcid" class="setting__row gap-top">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="field">
|
|
<label class="field__label" for="orcid">{{ tr "ORCID iD" }}</label>
|
|
<input id="orcid" class="control" type="text" name="orcid"
|
|
value="{{ .CurrentUserRecord.Orcid }}" placeholder="{{ tr "0000-0002-1825-0097" }}">
|
|
<p class="field__hint">{{ tr "Your Open Researcher and Contributor ID; it pre-fills the author field of new publications." }}</p>
|
|
</div>
|
|
<button type="submit" class="btn">{{ tr "Save ORCID" }}</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting">
|
|
<span class="card__icon" aria-hidden="true">
|
|
<svg><use href="/admin/assets/graphis.svg#lock"></use></svg>
|
|
</span>
|
|
<div class="setting__body">
|
|
<h3 class="setting__title">{{ tr "Security" }}</h3>
|
|
<p class="setting__desc">{{ tr "Change your password and username. You can also lock yourself out." }}</p>
|
|
<form method="post" action="/admin/settings/password">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="grid-2">
|
|
<div class="field">
|
|
<label class="field__label" for="current_password">{{ tr "Current password" }}</label>
|
|
<input id="current_password" class="control" type="password" name="current_password"
|
|
autocomplete="current-password">
|
|
</div>
|
|
<div class="field">
|
|
<label class="field__label" for="new_password">{{ tr "New password" }}</label>
|
|
<input id="new_password" class="control" type="password" name="new_password"
|
|
autocomplete="new-password" data-pw-meter
|
|
data-min-length="{{ .Config.Admin.MinPasswordLength }}"
|
|
aria-describedby="new-password-strength">
|
|
<div class="pw-level" data-level="0">
|
|
<div class="pw-level__bar" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
|
<span class="pw-level__label" id="new-password-strength" role="status" aria-live="polite"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="btn gap-top">{{ tr "Update password" }}</button>
|
|
</form>
|
|
|
|
<div class="totp gap-top" id="twofactor">
|
|
<h4 class="totp__title">{{ tr "Two-factor authentication" }}</h4>
|
|
{{if .RecoveryCodes}}
|
|
<div class="status status--info" role="status">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#key"></use></svg>
|
|
<div>{{ .RecoveryNotice }}</div>
|
|
</div>
|
|
<div class="recovery">
|
|
{{range .RecoveryCodes}}<code class="recovery__code">{{ . }}</code>{{end}}
|
|
</div>
|
|
{{else if .TotpEnabled}}
|
|
<p class="setting__desc">{{ tr "Your sign-in asks for a code from your application after the password." }}</p>
|
|
<div class="totp__forms">
|
|
<form method="post" action="/admin/settings/twofactor/codes" class="totp__form">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="field">
|
|
<label class="field__label" for="codes-code">{{ tr "Current code" }}</label>
|
|
<input id="codes-code" class="control mono" type="text" name="code"
|
|
inputmode="numeric" autocomplete="one-time-code" required>
|
|
</div>
|
|
<button type="submit" class="btn">{{ tr "Regenerate recovery codes" }}</button>
|
|
</form>
|
|
<form method="post" action="/admin/settings/twofactor/disable" class="totp__form"
|
|
data-confirm="{{ tr "Turn two-factor off? Your application will no longer be asked." }}">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="field">
|
|
<label class="field__label" for="off-code">{{ tr "Current code" }}</label>
|
|
<input id="off-code" class="control mono" type="text" name="code"
|
|
inputmode="numeric" autocomplete="one-time-code" required>
|
|
</div>
|
|
<button type="submit" class="btn btn--danger">{{ tr "Turn off" }}</button>
|
|
</form>
|
|
</div>
|
|
{{else if .TotpPending}}
|
|
<p class="setting__desc">{{ tr "Scan the code with your authenticator application, or enter the secret by hand, then confirm with the code it shows." }}</p>
|
|
<div class="totp__setup">
|
|
<div class="totp__qr">{{ .TotpSVG }}</div>
|
|
<div class="totp__setup-side">
|
|
<p class="totp__secret-label">{{ tr "Secret" }}</p>
|
|
<code class="totp__secret">{{ .TotpSecret }}</code>
|
|
<form method="post" action="/admin/settings/twofactor/verify" class="totp__form">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="field">
|
|
<label class="field__label" for="verify-code">{{ tr "Verification code" }}</label>
|
|
<input id="verify-code" class="control mono" type="text" name="code"
|
|
inputmode="numeric" autocomplete="one-time-code" required>
|
|
</div>
|
|
<button type="submit" class="btn btn--primary">{{ tr "Verify and enable" }}</button>
|
|
</form>
|
|
<form method="post" action="/admin/settings/twofactor/cancel">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<button type="submit" class="btn btn--ghost btn--sm">{{ tr "Cancel" }}</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{{else}}
|
|
<p class="setting__desc">{{ tr "Ask for a code from your authenticator application after the password. Voluntary: nothing changes until you finish the setup." }}</p>
|
|
<form method="post" action="/admin/settings/twofactor/start">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<button type="submit" class="btn">{{ tr "Set up two-factor" }}</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
|
|
<form method="post" action="/admin/settings/username" class="setting__row gap-top">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="field">
|
|
<label class="field__label" for="username">{{ tr "Username" }}</label>
|
|
<input id="username" class="control mono" type="text" name="username" value="{{ .CurrentUser }}">
|
|
</div>
|
|
<button type="submit" class="btn">{{ tr "Update username" }}</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{{if .IsAdmin}}
|
|
<section id="users" class="card card--flush">
|
|
<div class="card__head">
|
|
<h2>{{ tr "Users" }}</h2>
|
|
<p class="card__note">{{ tr "Add or remove admin and editor accounts" }}</p>
|
|
</div>
|
|
|
|
<div>
|
|
{{range .UserRows}}
|
|
<div class="user-row">
|
|
{{if .Photo}}
|
|
<span class="avatar"><img src="{{ .Photo }}" alt=""></span>
|
|
{{else}}
|
|
<span class="avatar">{{ .Initial }}</span>
|
|
{{end}}
|
|
<span class="user-row__info">
|
|
<span class="user-row__name">
|
|
{{ .Display }}
|
|
{{if .IsSelf}} <span class="tag tag--accent">{{ tr "you" }}</span>{{end}}
|
|
</span>
|
|
<span class="user-row__username">@{{ .Username }}</span>
|
|
</span>
|
|
<div class="user-row__actions">
|
|
{{if .IsSelf}}
|
|
<span class="tag">{{ tr .Role }}</span>
|
|
{{else}}
|
|
<form method="post" action="/admin/settings/users/{{ .Username }}/role" class="form-inline">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<select class="control control--sm" name="role" data-submit-on-change>
|
|
{{range .RoleOptions}}
|
|
<option value="{{ .Value }}" {{if .Selected}}selected{{end}}>{{ tr .Value }}</option>
|
|
{{end}}
|
|
</select>
|
|
</form>
|
|
<details class="user-row__reset">
|
|
<summary class="btn btn--ghost btn--sm">{{ tr "Reset password" }}</summary>
|
|
<form method="post" action="/admin/settings/users/{{ .Username }}/password"
|
|
class="user-row__reset-form"
|
|
data-confirm="{{ tr "Reset this user's password? Their sessions will be signed out." }}">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<input class="control control--sm" type="password" name="password"
|
|
autocomplete="new-password" placeholder="{{ tr "New password" }}" required
|
|
data-pw-meter data-min-length="{{ $.Config.Admin.MinPasswordLength }}"
|
|
aria-describedby="pw-strength-{{ .Username }}">
|
|
<div class="pw-level" data-level="0">
|
|
<div class="pw-level__bar" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
|
<span class="pw-level__label" id="pw-strength-{{ .Username }}" role="status" aria-live="polite"></span>
|
|
</div>
|
|
<button type="submit" class="btn btn--primary btn--sm">{{ tr "Reset password" }}</button>
|
|
</form>
|
|
</details>
|
|
<form method="post" action="/admin/settings/users/{{ .Username }}/delete"
|
|
data-confirm="{{ tr "Remove this user?" }}" class="form-inline">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Remove" }}</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
|
|
<form method="post" action="/admin/settings/users" class="user-add">
|
|
<h3>{{ tr "Add user" }}</h3>
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="grid-2">
|
|
<div class="field">
|
|
<label class="field__label" for="new-username">{{ tr "Username" }}</label>
|
|
<input id="new-username" class="control mono" type="text" name="username" placeholder="{{ tr "jane-doe" }}">
|
|
</div>
|
|
<div class="field">
|
|
<label class="field__label" for="new-password">{{ tr "Password" }}</label>
|
|
<input id="new-password" class="control" type="password" name="password" autocomplete="new-password"
|
|
data-pw-meter data-min-length="{{ .Config.Admin.MinPasswordLength }}"
|
|
aria-describedby="new-user-password-strength">
|
|
<div class="pw-level" data-level="0">
|
|
<div class="pw-level__bar" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
|
<span class="pw-level__label" id="new-user-password-strength" role="status" aria-live="polite"></span>
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label class="field__label" for="new-role">{{ tr "Role" }}</label>
|
|
<select id="new-role" class="control" name="role">
|
|
{{range .Roles}}
|
|
<option value="{{ . }}" {{if eq . $.DefaultRole}}selected{{end}}>{{ tr . }}</option>
|
|
{{end}}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="btn btn--primary">{{ tr "Add user" }}</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section id="templates" class="card">
|
|
<div class="card__head">
|
|
<h2>{{ tr "Post templates" }}</h2>
|
|
<p class="card__note">{{ tr "Pre-fill new posts with a reusable structure" }}</p>
|
|
</div>
|
|
|
|
{{if .TemplatesList}}
|
|
<ul class="kv-stack">
|
|
{{range .TemplatesList}}
|
|
<li class="kv-row">
|
|
<span><strong>{{ .Name }}</strong>{{if .Tags}}<span class="muted">: {{ join .Tags ", " }}</span>{{end}}{{if .FieldsText}}<span class="muted mono"> · {{ .FieldsText }}</span>{{end}}</span>
|
|
<form method="post" action="/admin/settings/templates/{{ .Name }}/delete"
|
|
data-confirm="{{ trf "Delete %s?" .Name }}" class="form-inline">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Delete" }}</button>
|
|
</form>
|
|
</li>
|
|
{{end}}
|
|
</ul>
|
|
{{else}}
|
|
<p class="setting__desc">{{ tr "No templates yet. Templates pre-fill the new-post form so you can keep your favourite structure on hand." }}</p>
|
|
{{end}}
|
|
|
|
<form method="post" action="/admin/settings/templates" class="stack gap-top">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="grid-2">
|
|
<div class="field">
|
|
<label class="field__label" for="tpl-name">{{ tr "Name" }}</label>
|
|
<input id="tpl-name" class="control" type="text" name="name" placeholder="{{ tr "e.g. Review" }}" required>
|
|
</div>
|
|
<div class="field">
|
|
<label class="field__label" for="tpl-title">{{ tr "Default title" }}</label>
|
|
<input id="tpl-title" class="control" type="text" name="title" placeholder="{{ tr "Optional" }}">
|
|
</div>
|
|
<div class="field">
|
|
<label class="field__label" for="tpl-slug">{{ tr "Default slug" }}</label>
|
|
<input id="tpl-slug" class="control mono" type="text" name="slug" placeholder="{{ tr "Optional" }}">
|
|
</div>
|
|
<div class="field">
|
|
<label class="field__label" for="tpl-tags">{{ tr "Tags (comma-separated)" }}</label>
|
|
<input id="tpl-tags" class="control" type="text" name="tags" placeholder="review, opinion">
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label class="field__label" for="tpl-body">{{ tr "Body template (Markdown)" }}</label>
|
|
<textarea id="tpl-body" class="control control--mono" name="body" rows="5"
|
|
placeholder="## Summary ## Verdict "></textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label class="field__label" for="tpl-fields">{{ tr "Fields to pre-fill" }}</label>
|
|
<textarea id="tpl-fields" class="control control--mono" name="fields" rows="4"
|
|
placeholder="series = "tds" doi = "10.5281/…" series_order = 3"></textarea>
|
|
<p class="field__hint">{{ tr "Extra editor inputs as TOML key = value lines, one per line, strings quoted: author, lang, doi, orcid, series, series_order, cover, excerpt." }}</p>
|
|
</div>
|
|
<button type="submit" class="btn btn--primary">{{ tr "Add template" }}</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section id="backup" class="card">
|
|
<div class="card__head">
|
|
<h2>{{ tr "Backup & restore" }}</h2>
|
|
<p class="card__note">{{ tr "Export or import all posts, media, users, and templates" }}</p>
|
|
</div>
|
|
|
|
<div class="grid-2">
|
|
<div class="stack">
|
|
<h3>{{ tr "Export" }}</h3>
|
|
<p class="setting__desc">{{ trh "Download a <code>.tar.gz</code> archive of your entire site." }}</p>
|
|
<a href="/admin/settings/export" class="btn btn--primary">{{ tr "Download backup" }}</a>
|
|
</div>
|
|
<div class="stack">
|
|
<h3>{{ tr "Import" }}</h3>
|
|
<p class="setting__desc">{{ tr "Restore from a previously exported archive. Overwrites existing data." }}</p>
|
|
<form method="post" action="/admin/settings/import" enctype="multipart/form-data"
|
|
data-confirm="{{ tr "This will overwrite existing posts and users. Continue?" }}">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="field">
|
|
<label class="visually-hidden" for="backup-file">{{ tr "Restore backup" }}</label>
|
|
<input id="backup-file" type="file" name="backup" accept=".tar.gz,.tgz" required>
|
|
</div>
|
|
<button type="submit" class="btn btn--danger gap-top">{{ tr "Restore backup" }}</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="version" class="card">
|
|
<div class="card__head">
|
|
<h2>{{ tr "Version" }}</h2>
|
|
<p class="card__note">{{ tr "Keep Volumen up to date" }}</p>
|
|
</div>
|
|
|
|
<div class="setting">
|
|
<span class="card__icon" aria-hidden="true">
|
|
<svg><use href="/admin/assets/graphis.svg#refresh"></use></svg>
|
|
</span>
|
|
<div class="setting__body">
|
|
<h3 class="setting__title">{{ tr "Installed version" }}</h3>
|
|
<p class="setting__desc">
|
|
{{ tr "Running" }} <strong class="mono">{{ .Version }}</strong>
|
|
{{if .UpdateAvailable}}
|
|
· {{ tr "latest" }} <strong class="mono">{{ .UpdateAvailable }}</strong>
|
|
{{end}}
|
|
</p>
|
|
{{if .UpdateAvailable}}
|
|
<div class="status status--warn">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
|
|
<div>
|
|
<strong>Volumen {{ .UpdateAvailable }}</strong> {{ tr "is available; you are running" }} {{ .Version }}.
|
|
{{ tr "The server will restart." }}
|
|
</div>
|
|
</div>
|
|
<form method="post" action="/admin/settings/update"
|
|
data-confirm="{{ trf "Update to %s now?" .UpdateAvailable }} {{ tr "The server will restart." }}">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<button type="submit" class="btn btn--primary">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#refresh"></use></svg>
|
|
{{ trf "Update to %s" .UpdateAvailable }}
|
|
</button>
|
|
</form>
|
|
{{else}}
|
|
<form method="post" action="/admin/settings/check-update">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<button type="submit" class="btn btn--ghost">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#repeat"></use></svg>
|
|
{{ tr "Check for updates" }}
|
|
</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="webhooks" class="card">
|
|
<div class="card__head">
|
|
<h2>{{ tr "Webhooks" }}</h2>
|
|
<p class="card__note">{{ tr "Notify external services when posts change" }}</p>
|
|
</div>
|
|
|
|
<div class="setting">
|
|
<span class="card__icon" aria-hidden="true">
|
|
<svg><use href="/admin/assets/graphis.svg#compare"></use></svg>
|
|
</span>
|
|
<div class="setting__body">
|
|
<h3 class="setting__title">{{ tr "Endpoints" }}</h3>
|
|
{{if not .WebhookRows}}
|
|
<p class="setting__desc">
|
|
{{ trh "No webhooks yet. Add an endpoint below, or declare <code>[[webhooks]]</code> blocks in <code>config.toml</code> (those are read-only here):" }}
|
|
</p>
|
|
<pre class="webhook-example"><code>[[webhooks]]
|
|
url = "https://example.com/hooks/rebuild"
|
|
secret = "a-long-random-string" # HMAC-SHA256 signing key
|
|
events = ["post.created", "post.updated", "post.deleted", "post.published"]</code></pre>
|
|
{{else}}
|
|
<p class="setting__desc">{{ tr "Signed JSON payloads are POSTed on post changes. Changes apply without a restart." }}</p>
|
|
<div class="card card--flush">
|
|
<table class="table">
|
|
<thead>
|
|
<tr><th>URL</th><th>{{ tr "Events" }}</th><th></th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .WebhookRows}}
|
|
<tr>
|
|
<td class="mono break-all">
|
|
{{ .URL }}
|
|
{{if not .Enabled}}<span class="tag tag--warn">{{ tr "disabled" }}</span>{{end}}
|
|
{{if .Signed}}<span class="tag">{{ tr "signed" }}</span>{{end}}
|
|
{{if not .Managed}}<span class="tag">{{ tr "from config" }}</span>{{end}}
|
|
</td>
|
|
<td>{{ .EventsText }}</td>
|
|
<td class="table__actions">
|
|
<form method="post" action="/admin/settings/webhooks/{{ .Index }}/test" class="form-inline">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<button type="submit" class="btn btn--ghost btn--sm">{{ tr "Send test" }}</button>
|
|
</form>
|
|
{{if .Managed}}
|
|
<form method="post" action="/admin/settings/webhooks/toggle" class="form-inline">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<input type="hidden" name="url" value="{{ .URL }}">
|
|
{{if .Enabled}}<button type="submit" class="btn btn--ghost btn--sm">{{ tr "Disable" }}</button>
|
|
{{else}}<button type="submit" class="btn btn--ghost btn--sm">{{ tr "Enable" }}</button>{{end}}
|
|
</form>
|
|
<form method="post" action="/admin/settings/webhooks/delete" class="form-inline"
|
|
data-confirm="{{ tr "Remove this webhook?" }}">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<input type="hidden" name="url" value="{{ .URL }}">
|
|
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Remove" }}</button>
|
|
</form>
|
|
{{end}}
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{end}}
|
|
<details>
|
|
<summary class="btn btn--ghost btn--sm">{{ tr "Add webhook" }}</summary>
|
|
<form method="post" action="/admin/settings/webhooks" class="stack gap-top">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="grid-2">
|
|
<div class="field">
|
|
<label class="field__label" for="hook-url">{{ tr "Endpoint URL" }}</label>
|
|
<input id="hook-url" class="control mono" type="url" name="url" required
|
|
placeholder="https://example.com/hooks/rebuild">
|
|
</div>
|
|
<div class="field">
|
|
<label class="field__label" for="hook-secret">{{ tr "Signing secret (optional)" }}</label>
|
|
<input id="hook-secret" class="control" type="text" name="secret" autocomplete="off">
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label class="field__label" for="hook-events">{{ tr "Events (comma-separated, empty for all)" }}</label>
|
|
<input id="hook-events" class="control mono" type="text" name="events"
|
|
placeholder="post.created, post.updated, post.deleted, post.published">
|
|
</div>
|
|
<label class="check">
|
|
<input type="checkbox" name="enabled" checked>
|
|
<span>{{ tr "Enabled" }}</span>
|
|
</label>
|
|
<button type="submit" class="btn">{{ tr "Add" }}</button>
|
|
</form>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
|
|
{{if .WebhookDeliveries}}
|
|
<div class="setting">
|
|
<span class="card__icon" aria-hidden="true">
|
|
<svg><use href="/admin/assets/graphis.svg#activity"></use></svg>
|
|
</span>
|
|
<div class="setting__body">
|
|
<h3 class="setting__title">{{ tr "Recent deliveries" }}</h3>
|
|
<p class="setting__desc">{{ tr "Kept in memory; cleared on restart." }}</p>
|
|
<div class="card card--flush">
|
|
<table class="table">
|
|
<thead>
|
|
<tr><th>{{ tr "When" }}</th><th>{{ tr "Event" }}</th><th>{{ tr "Endpoint" }}</th><th>{{ tr "Result" }}</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .WebhookDeliveries}}
|
|
<tr>
|
|
<td class="mono">{{ .Timestamp }}</td>
|
|
<td><span class="tag">{{ .Event }}</span></td>
|
|
<td class="mono break-all">{{ .HookURL }}</td>
|
|
<td>
|
|
{{if .OK}}
|
|
<span class="ok-text mono">HTTP {{ .StatusCode }}</span>
|
|
{{else}}
|
|
<span class="danger-text" title="{{ .Error }}">{{ .Result }}</span>
|
|
{{end}}
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
</section>
|
|
|
|
<section id="tokens" class="card">
|
|
<div class="card__head">
|
|
<h2>{{ tr "API tokens" }}</h2>
|
|
<p class="card__note">{{ tr "Programmatic write access to the public API" }}</p>
|
|
</div>
|
|
|
|
<div class="setting">
|
|
<span class="card__icon" aria-hidden="true">
|
|
<svg><use href="/admin/assets/graphis.svg#key"></use></svg>
|
|
</span>
|
|
<div class="setting__body">
|
|
<h3 class="setting__title">{{ tr "Access tokens" }}</h3>
|
|
<p class="setting__desc">{{ trh "Send a token as <code>Authorization: Bearer <token></code> on <code>POST/PUT/DELETE /api/volumen/posts</code>." }}</p>
|
|
{{if .NewToken}}
|
|
<div class="status status--warn">
|
|
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-triangle"></use></svg>
|
|
<div>
|
|
<strong>{{ tr "Copy this token now; it will never be shown again." }}</strong>
|
|
<div class="gap-top"><code class="select-all">{{ .NewToken }}</code></div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
{{if .TokenRows}}
|
|
<div class="card card--flush">
|
|
<table class="table">
|
|
<thead>
|
|
<tr><th>{{ tr "Name" }}</th><th>{{ tr "Created" }}</th><th>{{ tr "Last used" }}</th><th></th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .TokenRows}}
|
|
<tr>
|
|
<td><strong>{{ .Name }}</strong></td>
|
|
<td class="mono">{{ .CreatedDay }}</td>
|
|
<td class="mono">{{ .LastUsedDay }}</td>
|
|
<td class="table__actions">
|
|
<form method="post" action="/admin/settings/tokens/{{ .Name }}/delete" class="form-inline"
|
|
data-confirm="{{ trf "Revoke token “%s”? Scripts using it will stop working." .Name }}">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<button type="submit" class="btn btn--danger btn--sm">{{ tr "Revoke" }}</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{else}}
|
|
<p class="setting__desc">{{ tr "No tokens yet. Create one to publish posts from scripts or CI." }}</p>
|
|
{{end}}
|
|
<form method="post" action="/admin/settings/tokens" class="stack gap-top">
|
|
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
|
|
<div class="setting__row">
|
|
<div class="field">
|
|
<label class="field__label" for="token-name">{{ tr "Token name" }}</label>
|
|
<input id="token-name" class="control" type="text" name="name"
|
|
placeholder="{{ tr "e.g. deploy-script" }}" required>
|
|
</div>
|
|
<button type="submit" class="btn btn--primary">{{ tr "Create token" }}</button>
|
|
</div>
|
|
<fieldset class="field">
|
|
<legend class="field__label">{{ tr "Scopes" }}</legend>
|
|
<div class="check-row">
|
|
<label class="check">
|
|
<input type="checkbox" name="scope" value="write" checked>
|
|
<span>{{ tr "Write" }}</span>
|
|
</label>
|
|
<label class="check">
|
|
<input type="checkbox" name="scope" value="delete">
|
|
<span>{{ tr "Delete" }}</span>
|
|
</label>
|
|
</div>
|
|
<p class="field__hint">{{ tr "Write creates and edits posts; delete removes them. Read endpoints are public, so no scope is needed for them. Selecting neither grants full access." }}</p>
|
|
</fieldset>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<script nonce="{{ .CSPNonce }}">
|
|
/* The settings panels switch one at a time: the URL keeps the section,
|
|
and a form POST that lands without a fragment restores the section
|
|
that was last open. */
|
|
(() => {
|
|
const sections = document.querySelectorAll(".settings__panels section[id]");
|
|
const navLinks = document.querySelectorAll(".settings__nav a[href^='#']");
|
|
if (!sections.length || !navLinks.length) return;
|
|
|
|
const KEY = "volumen.settings.section";
|
|
const byId = (id) => Array.from(sections).find((s) => s.id === id);
|
|
|
|
function showSection(id) {
|
|
sections.forEach((s) => { s.hidden = s.id !== id; });
|
|
navLinks.forEach((a) => {
|
|
a.setAttribute("aria-current", String(a.getAttribute("href") === "#" + id));
|
|
});
|
|
try {
|
|
history.replaceState(null, "", "/admin/settings#" + id);
|
|
sessionStorage.setItem(KEY, id);
|
|
} catch (error) {}
|
|
window.scrollTo({ top: 0 });
|
|
}
|
|
|
|
navLinks.forEach((link) => {
|
|
link.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
showSection(link.getAttribute("href").slice(1));
|
|
});
|
|
});
|
|
window.addEventListener("hashchange", () => {
|
|
const target = byId(window.location.hash.slice(1));
|
|
if (target) showSection(target.id);
|
|
});
|
|
|
|
let initial = byId(window.location.hash.slice(1));
|
|
if (!initial) {
|
|
let remembered = "";
|
|
try { remembered = sessionStorage.getItem(KEY) || ""; } catch (error) {}
|
|
initial = byId(remembered) || sections[0];
|
|
}
|
|
showSection(initial.id);
|
|
})();
|
|
</script>
|
|
{{end}}
|