Files
MeshTender/internal/auth/templates/account.html
T

387 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{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 (332 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}}