mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-17 00:44:20 +00:00
387 lines
21 KiB
HTML
387 lines
21 KiB
HTML
{{define "title"}}Account settings · MeshTender{{end}}
|
||
{{define "header"}}
|
||
<div class="row g-2 align-items-center">
|
||
<div class="col">
|
||
<div class="page-pretitle">Your account</div>
|
||
<h1 class="page-title fs-1">Account settings</h1>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
{{define "content"}}
|
||
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
|
||
{{if .OK}}<div class="alert alert-success">{{.OK}}</div>{{end}}
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Username</h2></div>
|
||
<div class="card-body">
|
||
<p class="text-secondary">Your username is your unique handle (3–32 chars: letters, digits, <code>_ . -</code>). You can change it once every 30 days. When you do, your old username is reserved for 90 days before anyone else can take it.</p>
|
||
<form method="post" action="/account/username">
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md">
|
||
<label class="form-label" for="acct_username">Username</label>
|
||
<div class="input-group input-group-flat">
|
||
<span class="input-group-text">@</span>
|
||
<input type="text" class="form-control" id="acct_username" name="username" minlength="3" maxlength="32" pattern="[a-zA-Z0-9_.\-]+" value="{{.User.Username}}" {{if .NextRename}}disabled{{end}}>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-auto">
|
||
<button type="submit" class="btn btn-primary" {{if .NextRename}}disabled{{end}}>Change username</button>
|
||
</div>
|
||
</div>
|
||
{{if .NextRename}}
|
||
<small class="form-hint mt-2 d-block">You changed your username recently. You can change it again on {{ts .NextRename "date"}}.</small>
|
||
{{end}}
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mt-3">
|
||
<div class="card-header"><h2 class="card-title">Display name</h2></div>
|
||
<div class="card-body">
|
||
<p class="text-secondary">Your display name is what other people see — on shares and in organizations. It can be anything, and it isn't tied to sign-in.</p>
|
||
<form method="post" action="/account/profile">
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md">
|
||
<label class="form-label" for="acct_display_name">Display name <span class="form-label-description">optional</span></label>
|
||
<input type="text" class="form-control" id="acct_display_name" name="display_name" maxlength="64" value="{{if .DisplayName}}{{.DisplayName}}{{end}}" placeholder="Shown to people you share with">
|
||
</div>
|
||
<div class="col-md-auto">
|
||
<button type="submit" class="btn btn-primary">Save display name</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
{{/* Email card. Hidden entirely when no mail provider is configured: an address we
|
||
can't send to would be a promise we can't keep. What the card SAYS depends on
|
||
three things — whether an address is set, whether it's confirmed, and whether
|
||
there's a password to reset — because a passkey-only account's address genuinely
|
||
can't recover it, and implying otherwise is the one thing worse than silence.
|
||
|
||
Placed here, with the account-identity fields, because that's where people look
|
||
for it — but it sits directly above the two PUBLIC cards, so the header carries
|
||
"(private)" the way Links carries "(public)". Position reads louder than body
|
||
copy, and an address nobody meant to publish is the one mistake worth this much
|
||
redundancy. */}}
|
||
{{if .MailEnabled}}
|
||
<div class="card mt-3">
|
||
<div class="card-header"><h2 class="card-title">Email <span class="text-secondary">(private)</span></h2></div>
|
||
<div class="card-body">
|
||
<p class="text-secondary">
|
||
Optional. Used to recover your account if you forget your password. It's never shown
|
||
on your public profile, and we only email you about your account — never marketing.
|
||
</p>
|
||
{{if .EmErr}}<div class="alert alert-danger">{{.EmErr}}</div>{{end}}
|
||
{{if .EmMsg}}<div class="alert alert-success">{{.EmMsg}}</div>{{end}}
|
||
|
||
{{if .Email}}
|
||
<div class="list-group list-group-flush mb-3">
|
||
<div class="list-group-item d-flex align-items-center gap-3 px-0">
|
||
<span class="text-secondary">{{template "icon-mail" ""}}</span>
|
||
<div class="flex-fill" style="min-width:0">
|
||
<div class="text-truncate">{{.Email}}</div>
|
||
<div class="text-secondary small">
|
||
{{if .EmailVerified}}
|
||
{{if .CanResetByMail}}
|
||
<span class="badge bg-success-lt">confirmed</span> You can reset your password by email.
|
||
{{else}}
|
||
<span class="badge bg-success-lt">confirmed</span>
|
||
This account has no password, so there's nothing to reset — sign-in is by passkey only.
|
||
To keep access if you lose your device, add a passkey on a second device in the Passkeys section below.
|
||
{{end}}
|
||
{{else}}
|
||
<span class="badge bg-yellow-lt">unconfirmed</span>
|
||
Check your inbox for the confirmation link. Until it's confirmed, this address can't recover your account.
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
{{if not .EmailVerified}}
|
||
<form method="post" action="/account/email/verify" class="m-0">
|
||
<button type="submit" class="btn btn-sm" data-testid="email-resend">Resend</button>
|
||
</form>
|
||
{{end}}
|
||
<form method="post" action="/account/email" class="m-0" data-confirm="Remove your email address? If you forget your password, it can't be reset without one.">
|
||
<input type="hidden" name="remove" value="1">
|
||
<button type="submit" class="btn btn-sm btn-ghost-danger" data-testid="email-remove">Remove</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
<form method="post" action="/account/email">
|
||
<div class="row g-2 align-items-end">
|
||
<div class="col-sm">
|
||
<label class="form-label" for="email">{{if .Email}}Change address{{else}}Email address{{end}} <span class="form-label-description">optional</span></label>
|
||
<input type="email" class="form-control" id="email" name="email" autocomplete="email" autocapitalize="none" maxlength="254" placeholder="you@example.com" required>
|
||
</div>
|
||
<div class="col-sm-auto">
|
||
<button type="submit" class="btn btn-primary w-100" data-testid="email-save">{{if .Email}}Save new address{{else}}Save address{{end}}</button>
|
||
</div>
|
||
</div>
|
||
<small class="form-hint mt-1">We'll send a confirmation link. A new address has to be confirmed before it can recover your account.</small>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
<div class="card mt-3">
|
||
<div class="card-header"><h2 class="card-title">Time zone</h2></div>
|
||
<div class="card-body">
|
||
<p class="text-secondary">Dates and times across MeshTender are shown in this time zone (and in your browser's locale format). Leave it on <strong>Auto-detect</strong> to use whatever zone your browser reports.</p>
|
||
<form method="post" action="/account/timezone">
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md">
|
||
<label class="form-label" for="acct_timezone">Time zone</label>
|
||
{{/* The full IANA list is filled in by timezone-picker.js from the
|
||
browser's own Intl database. These options are the no-JS fallback
|
||
and preserve the current value on round-trip. data-current carries
|
||
the saved zone so the script can reselect it. */}}
|
||
<select class="form-select" id="acct_timezone" name="timezone" data-tz-picker data-current="{{.Timezone}}">
|
||
<option value=""{{if not .Timezone}} selected{{end}}>Auto-detect (browser)</option>
|
||
{{if .Timezone}}<option value="{{.Timezone}}" selected>{{.Timezone}}</option>{{end}}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-auto">
|
||
<button type="submit" class="btn btn-primary" data-testid="tz-save">Save time zone</button>
|
||
</div>
|
||
</div>
|
||
{{/* Detected-zone hint sits below the row so it doesn't unbalance the
|
||
select/button alignment (align-items-end). */}}
|
||
<small class="form-hint mt-2 d-block" data-tz-detected></small>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mt-3">
|
||
<div class="card-header"><h2 class="card-title">Public profile</h2></div>
|
||
<div class="card-body">
|
||
<p class="text-secondary">These appear on your public page at <a href="{{.RootURL}}/u/{{.User.Username}}" target="_blank" rel="noopener">{{.RootURL}}/u/{{.User.Username}}</a>, which anyone can view. Leave a field blank to keep it off your page.</p>
|
||
<form method="post" action="/account/profile-fields">
|
||
<div class="mb-3">
|
||
<label class="form-label" for="acct_bio">Bio <span class="form-label-description">optional</span></label>
|
||
<textarea class="form-control" id="acct_bio" name="bio" rows="3" maxlength="500" placeholder="A short blurb about you">{{.Bio}}</textarea>
|
||
</div>
|
||
<div class="row g-3">
|
||
<div class="col-md">
|
||
<label class="form-label" for="acct_location">Location <span class="form-label-description">optional</span></label>
|
||
<input type="text" class="form-control" id="acct_location" name="location" maxlength="120" value="{{.Location}}" placeholder="General area you operate in">
|
||
</div>
|
||
<div class="col-md">
|
||
<label class="form-label" for="acct_callsign">Callsign <span class="form-label-description">optional</span></label>
|
||
<input type="text" class="form-control" id="acct_callsign" name="callsign" maxlength="32" value="{{.Callsign}}" placeholder="e.g. KD2ABC">
|
||
</div>
|
||
</div>
|
||
<div class="mt-3">
|
||
<button type="submit" class="btn btn-primary">Save profile</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mt-3">
|
||
<div class="card-header"><h2 class="card-title">Links <span class="text-secondary">(public)</span></h2></div>
|
||
<div class="card-body">
|
||
<p class="text-secondary">Ways to reach or find you — social media, a website, or your MeshCore public key (shown as a QR code). Mark one non-MeshCore link as your <strong>primary contact</strong> so people know the best way to reach you.</p>
|
||
<form method="post" action="/account/links" data-link-editor>
|
||
<div data-link-rows>
|
||
{{range $i, $l := .Links}}
|
||
<div class="link-row row g-2 mb-2 align-items-center">
|
||
<div class="col-12 col-sm-3">
|
||
<select class="form-select link-platform" name="link_platform" aria-label="Link type">
|
||
{{$sel := $l.Platform}}
|
||
{{range $.Platforms}}<option value="{{.Key}}"{{if eq .Key $sel}} selected{{end}}>{{.Name}}</option>{{end}}
|
||
</select>
|
||
</div>
|
||
<div class="col-12 col-sm-3 link-label-col">
|
||
<input class="form-control link-label" name="link_label" value="{{$l.Label}}" maxlength="60" placeholder="Label (optional)" aria-label="Link label">
|
||
</div>
|
||
<div class="col">
|
||
<input class="form-control link-value" type="text" name="link_url" value="{{$l.URL}}" maxlength="300" aria-label="Link value">
|
||
</div>
|
||
<div class="col-auto">
|
||
<label class="form-check form-check-inline m-0" title="Primary contact">
|
||
<input class="form-check-input" type="radio" name="link_primary" value="{{$i}}"{{if $l.IsPrimary}} checked{{end}}>
|
||
<span class="form-check-label">Primary</span>
|
||
</label>
|
||
</div>
|
||
<div class="col-auto">
|
||
<button type="button" class="btn btn-ghost-danger btn-icon remove-link" aria-label="Remove link">{{template "icon-trash" ""}}</button>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
<div class="btn-list mt-2">
|
||
<button type="button" class="btn" data-link-add>{{template "icon-plus" "me-1"}}Add link</button>
|
||
<button type="submit" class="btn btn-primary">Save links</button>
|
||
</div>
|
||
<template data-link-tpl>
|
||
<div class="link-row row g-2 mb-2 align-items-center">
|
||
<div class="col-12 col-sm-3">
|
||
<select class="form-select link-platform" name="link_platform" aria-label="Link type">
|
||
{{range .Platforms}}<option value="{{.Key}}">{{.Name}}</option>{{end}}
|
||
</select>
|
||
</div>
|
||
<div class="col-12 col-sm-3 link-label-col">
|
||
<input class="form-control link-label" name="link_label" maxlength="60" placeholder="Label (optional)" aria-label="Link label">
|
||
</div>
|
||
<div class="col">
|
||
<input class="form-control link-value" type="text" name="link_url" maxlength="300" aria-label="Link value">
|
||
</div>
|
||
<div class="col-auto">
|
||
<label class="form-check form-check-inline m-0" title="Primary contact">
|
||
<input class="form-check-input" type="radio" name="link_primary" value="">
|
||
<span class="form-check-label">Primary</span>
|
||
</label>
|
||
</div>
|
||
<div class="col-auto">
|
||
<button type="button" class="btn btn-ghost-danger btn-icon remove-link" aria-label="Remove link">{{template "icon-trash" ""}}</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</form>
|
||
<script nonce="{{.Nonce}}">window.MESHTENDER_LINK_PLATFORMS = {{.PlatformsJS}};</script>
|
||
<script src="{{ asset "/static/link-editor.js" }}"></script>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mt-3">
|
||
<div class="card-header"><h2 class="card-title">Passkeys</h2></div>
|
||
<div class="card-body">
|
||
<p class="text-secondary">Passkeys let you sign in with your device's fingerprint, face, or PIN — no password to remember or leak.</p>
|
||
{{if .PKErr}}<div class="alert alert-danger">{{.PKErr}}</div>{{end}}
|
||
{{if .PKMsg}}<div class="alert alert-success">{{.PKMsg}}</div>{{end}}
|
||
{{if .Passkeys}}
|
||
<div class="subheader mt-3 mb-2">Your passkeys</div>
|
||
<div class="list-group list-group-flush mb-3">
|
||
{{range .Passkeys}}
|
||
<div class="list-group-item d-flex align-items-center gap-3 px-0">
|
||
<span class="text-secondary">{{template "icon-key" ""}}</span>
|
||
<div class="flex-fill" style="min-width:0">
|
||
<div class="text-truncate">{{if .Name}}{{.Name}}{{else}}<span class="text-secondary fst-italic">Unnamed passkey</span>{{end}}</div>
|
||
<div class="text-secondary small"><span class="font-monospace">{{.ShortID}}…</span> · Added {{ts .Added "date"}}</div>
|
||
</div>
|
||
<button type="button" class="btn btn-sm" data-bs-toggle="modal" data-bs-target="#rename-passkey" data-id="{{.ID}}" data-name="{{.Name}}">Rename</button>
|
||
<form method="post" action="/account/passkeys/delete" class="m-0" data-confirm="Remove this passkey? You won't be able to sign in with it anymore.">
|
||
<input type="hidden" name="credential_id" value="{{.ID}}">
|
||
<button type="submit" class="btn btn-sm btn-ghost-danger">Remove</button>
|
||
</form>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
{{else}}
|
||
<p class="text-secondary">No passkeys yet. Add one for faster, more secure sign-in.</p>
|
||
{{end}}
|
||
|
||
<div class="subheader mb-2">Add a passkey</div>
|
||
<div class="row g-2 align-items-end">
|
||
<div class="col-sm">
|
||
<label class="form-label" for="new_passkey_name">Description <span class="form-label-description">optional</span></label>
|
||
<input type="text" class="form-control" id="new_passkey_name" maxlength="64" placeholder="e.g. MacBook Touch ID">
|
||
</div>
|
||
<div class="col-sm-auto">
|
||
<button type="button" class="btn btn-primary w-100" id="add-passkey-btn">{{template "icon-key" "me-1"}}Add a passkey</button>
|
||
</div>
|
||
</div>
|
||
<p id="passkey-status" class="text-secondary small mt-2 mb-0" style="min-height:1.2em"></p>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mt-3">
|
||
<div class="card-header"><h2 class="card-title">Password</h2></div>
|
||
<div class="card-body">
|
||
{{if .HasPassword}}
|
||
<p class="text-secondary">Change the password you use as a fallback to passkeys.</p>
|
||
{{else}}
|
||
<p class="text-secondary">You don't have a password set — you sign in with a passkey. You can add one as a fallback.</p>
|
||
{{end}}
|
||
<form method="post" action="/account/password">
|
||
<div class="row g-3">
|
||
{{if .HasPassword}}
|
||
<div class="col-md">
|
||
<label class="form-label" for="cur_pw">Current password</label>
|
||
<div class="input-group input-group-flat">
|
||
<input type="password" class="form-control" id="cur_pw" name="current_password" autocomplete="current-password" required>
|
||
<span class="input-group-text"><button type="button" class="link-secondary border-0 bg-transparent p-0" aria-pressed="false" title="Show password" data-pwtoggle="cur_pw" aria-label="Show password">{{template "icon-eye" ""}}</button></span>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
<div class="col-md">
|
||
<label class="form-label" for="new_pw">{{if .HasPassword}}New password{{else}}Password{{end}} <span class="form-label-description">min {{.MinPasswordLen}} characters</span></label>
|
||
<div class="input-group input-group-flat">
|
||
<input type="password" class="form-control" id="new_pw" name="new_password" autocomplete="new-password" minlength="{{.MinPasswordLen}}" required>
|
||
<span class="input-group-text"><button type="button" class="link-secondary border-0 bg-transparent p-0" aria-pressed="false" title="Show password" data-pwtoggle="new_pw" aria-label="Show password">{{template "icon-eye" ""}}</button></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3">
|
||
<button type="submit" class="btn btn-primary">{{if .HasPassword}}Change password{{else}}Set password{{end}}</button>
|
||
</div>
|
||
</form>
|
||
|
||
{{if .HasPassword}}
|
||
{{/* The confirm text names the recovery consequence, not just the sign-in one:
|
||
email reset only ever sets a password on an account that HAS one, so
|
||
removing it silently turns a working recovery path into a dead end. */}}
|
||
<form method="post" action="/account/password" class="mt-3" data-confirm="Remove your password? You'll sign in with a passkey only — and email recovery will no longer apply to this account, since there'd be no password to reset.">
|
||
<input type="hidden" name="remove" value="1">
|
||
<button type="submit" class="btn btn-ghost-danger">Remove password</button>
|
||
<small class="form-hint mt-1">Only available if you have at least one passkey.</small>
|
||
</form>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
{{/* Deletion is a page, not a button here: what it removes depends on what you
|
||
own, and that has to be shown before anyone commits to it. */}}
|
||
<div class="card mt-3 border-danger">
|
||
<div class="card-header"><h2 class="card-title text-danger">Delete account</h2></div>
|
||
<div class="card-body">
|
||
<p class="text-secondary">
|
||
Permanently delete your account, your public profile, and the repeaters you own. This can't be
|
||
undone — the next page shows exactly what would go, and lets you hand any repeater to a steward
|
||
first.
|
||
</p>
|
||
<a class="btn btn-outline-danger" href="/account/delete" data-testid="delete-account">
|
||
{{template "icon-trash" "me-1"}}Delete my account…
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rename-passkey modal: populated from the clicked row's data-* attributes. -->
|
||
<div class="modal modal-blur fade" id="rename-passkey" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
|
||
<div class="modal-content">
|
||
<form method="post" action="/account/passkeys/rename">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">Rename passkey</h2>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<input type="hidden" name="credential_id" id="rename-id">
|
||
<label class="form-label" for="rename-name">Description <span class="form-label-description">optional</span></label>
|
||
<input type="text" class="form-control" name="name" id="rename-name" maxlength="64" placeholder="e.g. MacBook Touch ID" autocomplete="off">
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-link link-secondary me-auto" data-bs-dismiss="modal">Cancel</button>
|
||
<button type="submit" class="btn btn-primary">Save</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script nonce="{{.Nonce}}">
|
||
// Fill the rename modal from the passkey row that opened it.
|
||
document.getElementById("rename-passkey").addEventListener("show.bs.modal", function (e) {
|
||
var btn = e.relatedTarget;
|
||
if (!btn) return;
|
||
document.getElementById("rename-id").value = btn.getAttribute("data-id");
|
||
document.getElementById("rename-name").value = btn.getAttribute("data-name") || "";
|
||
});
|
||
</script>
|
||
<script src="{{ asset "/static/timezone-picker.js" }}"></script>
|
||
<script src="{{ asset "/static/webauthn.js" }}"></script>
|
||
{{end}}
|