password-reset: a confirmed e-mail, then a link to reset the password
Agreed by the owner on 2026-10-13 ("the e-mail must be confirmed");
notifications to an unconfirmed address settled on 2026-10-17: "Без
подтверждения слать". Mockups 2f5f5b2, route guard b7bbb2c, panel guard
0774da2.
Migration 0013: users.email_confirmed_at ('' for every address saved
before it) and user_links - the SHA-256 of a link's token, its user,
purpose (confirm, reset), address, created and expiry. A changed address
loses its mark (keepConfirmed, in UpdateUser and SaveUser).
internal/web/auth/links.go. A link is 256 bits from crypto/rand, mailed as
https://SELFPOST_HOSTNAME/... - never the request's Host. Confirmation:
24 h, sent when an address is saved (Account, or the global role's user
form) and by Account's Send the link again; its page /confirm/<token> has
a button (a GET changes nothing) and confirms only for a session of its
own account. Reset: /login/reset asks for a username and answers the same
for every name (the sign-in page, "If that account has a confirmed e-mail,
the link is on its way"), mailing after the answer; only a confirmed
address gets one, none within ten minutes of the last, five links an hour
per account, five requests in 15 minutes per client address;
/login/reset/<token> (30 min) sets the password with the link taken in
the same transaction that checks the address again
(store.TakeResetLink), then ends every session. Changing the e-mail on
Account asks for the current password. A password set on Account or the
user form removes the reset links; a restore removes every link; the stop
waits for links being mailed (Server.Drain); a rejected request's log line
hides a link's token (loggedPath).
Reviewed for security before the commit (2026-10-17): no high; two
medium and five low, all fixed here - confirming needs the account's
session and the mail does not name the account (a mistyped address was a
takeover); a reset request neither replaces the links already sent nor
spends the account's quota inside ten minutes; take-and-set atomic; the
confirmation mail sent after the answer; the stop waits for mail; a
restore deletes the links; the token kept out of the log.
Tests: store TestUserLinks, TestUserLinkTakenOnce, TestEmailConfirmation,
TestTakeResetLink; auth TestResetSendsALinkOnlyToAConfirmedAddress,
TestResetLinkSetsThePasswordOnce, TestResetLinkDiesWithTheAddress,
TestResetRequestsAreLimited, TestResetRequestsAreSpaced, TestConfirmLink,
TestLinkPathsWithoutAToken; handlers TestAccountEmailIsConfirmedByItsLink,
TestUserFormSendsANewAddressItsLink; web TestLoggedPathHidesTokens; the
view's pages and fixtures; e2e password_reset_by_e_mail runs it through
the sink (and checks a link from before the restore is dead). go vet +
go test (26 packages) and make e2e on the test server for this tree.
Docs: guide § Account, security.md (the bullet and what ends sessions),
architecture (routes), schema-migrations (row 13, users, user_links),
help #account (word for word with the mockup), roadmap row done,
CHANGELOG [Unreleased].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,10 +27,41 @@ type Account struct {
|
||||
// Notify is whether the panel writes to this user: the checkbox under the
|
||||
// e-mail.
|
||||
Notify bool
|
||||
// EmailTag says whether the stored address is confirmed (nil without
|
||||
// one); Resend is the form that sends its link again, nil once it is
|
||||
// confirmed (password-reset).
|
||||
EmailTag *Tag
|
||||
Resend *ResendLink
|
||||
|
||||
Password Box
|
||||
}
|
||||
|
||||
// ResendLink is Account's "Send the link again": the address and when its
|
||||
// live link went ("" for none).
|
||||
type ResendLink struct {
|
||||
Email string
|
||||
Sent string
|
||||
}
|
||||
|
||||
// WithEmailState sets the stored address's tag and, while it is not
|
||||
// confirmed, the resend form: sentAt is when its live link went, zero for
|
||||
// none.
|
||||
func (a *Account) WithEmailState(email string, confirmed bool, sentAt time.Time) *Account {
|
||||
if email == "" {
|
||||
return a
|
||||
}
|
||||
if confirmed {
|
||||
a.EmailTag = &Tag{Status: "ok", Label: "confirmed"}
|
||||
return a
|
||||
}
|
||||
a.EmailTag = &Tag{Status: "warn", Label: "not confirmed"}
|
||||
a.Resend = &ResendLink{Email: email}
|
||||
if !sentAt.IsZero() {
|
||||
a.Resend.Sent = sentAt.Local().Format("15:04")
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// NewAccount builds the page for a user. role is "global" or "domain" and
|
||||
// appears in the kicker.
|
||||
func NewAccount(m Meta, role string) *Account {
|
||||
|
||||
@@ -21,13 +21,20 @@ import (
|
||||
// This file belongs to the implementation, not to the guards: the step that
|
||||
// restyles a page adds its fixture here in the same commit.
|
||||
var pageFixtures = map[string]func() any{
|
||||
"components": func() any { return KitPage() },
|
||||
"login": func() any { return NewLogin("mail.example.org", "") },
|
||||
"setup": func() any { return NewSetup("0123456789abcdef", "") },
|
||||
"overview": func() any { return overviewFixture() },
|
||||
"health": func() any { return healthFixture(false) },
|
||||
"account": func() any { return accountFixture() },
|
||||
"settings": func() any { return settingsFixture() },
|
||||
"components": func() any { return KitPage() },
|
||||
"login": func() any { return NewLogin("mail.example.org", "") },
|
||||
"setup": func() any { return NewSetup("0123456789abcdef", "") },
|
||||
"login-reset": func() any { return NewReset("mail.example.org", "") },
|
||||
"login-reset-set": func() any {
|
||||
return NewResetSet("mail.example.org", "admin", "0123456789abcdef", "")
|
||||
},
|
||||
"confirm-email": func() any {
|
||||
return NewConfirm("mail.example.org", "admin", "mix@example.org", "0123456789abcdef")
|
||||
},
|
||||
"overview": func() any { return overviewFixture() },
|
||||
"health": func() any { return healthFixture(false) },
|
||||
"account": func() any { return accountFixture() },
|
||||
"settings": func() any { return settingsFixture() },
|
||||
|
||||
"out-domains": func() any { return outDomainsFixture() },
|
||||
"out-domain": func() any { return outDomainFixture() },
|
||||
@@ -141,6 +148,8 @@ func healthFixture(refresh bool) *Health {
|
||||
func accountFixture() *Account {
|
||||
a := NewAccount(admin(), "global")
|
||||
a.Username, a.Email, a.Notify = "admin", "mix@example.org", true
|
||||
// The mockup's address: not confirmed, its link sent at 14:02.
|
||||
a.WithEmailState("mix@example.org", false, time.Date(2026, 10, 17, 14, 2, 0, 0, time.UTC))
|
||||
return a
|
||||
}
|
||||
|
||||
@@ -438,7 +447,7 @@ func userFixture() *UserForm {
|
||||
AllOut: true,
|
||||
OutDomains: []DomainChoice{{ID: 1, Name: "example.org"}, {ID: 2, Name: "shop.example.org"}, {ID: 3, Name: "notify.acme.io"}},
|
||||
InDomains: []DomainChoice{{ID: 1, Name: "lists.example.org"}, {ID: 2, Name: "acme.io", Checked: true}},
|
||||
})
|
||||
}).WithEmailState("ops@acme.io", true)
|
||||
}
|
||||
|
||||
// userDeleteFixture is the mockup's confirmation: acme-ops, who has all
|
||||
|
||||
@@ -305,6 +305,7 @@ var serverTopics = []HelpTopic{
|
||||
|
||||
var accountTopics = []HelpTopic{
|
||||
{ID: HelpAccount, Title: "Your account", Body: []Text{
|
||||
Plain("Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected."),
|
||||
Rich("Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected. A new e-mail is sent a link that confirms it, which works once, for 24 hours; changing the e-mail asks for your password. Notifications go to the address whether it is confirmed or not, but only a confirmed one can get a link to reset a forgotten password: ",
|
||||
Em("Forgot your password?"), " on the sign-in page sends it, for 30 minutes and once, and setting the new password signs every session of the account out. Without a confirmed e-mail the global administrator sets a new password under Users, and the only global administrator recovers from the container."),
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -290,14 +290,18 @@ func TestAccountHasTwoFormsEachItsOwnPost(t *testing.T) {
|
||||
`<h1 class="title is-3">Account</h1>`, `Signed in as admin · global`,
|
||||
`action="/account/profile"`, `name="username"`, `name="email"`, `value="mix@example.org"`,
|
||||
`action="/account/password"`, `name="current_password"`, `name="new_password"`, `name="new_password_confirm"`,
|
||||
`Your own address. A domain's report address can be filled with it from Domain settings.`,
|
||||
`Your own address. Saving a new one sends it a link to confirm it; once confirmed it is where a link to reset your password can go. A domain's report address can be filled with it from Domain settings.`,
|
||||
`E-mail <span class="tag is-warning is-light">not confirmed</span>`,
|
||||
`<input class="input" id="profile_password" name="current_password" type="password" autocomplete="current-password">`,
|
||||
`action="/account/email/confirm"`, `A link was sent to <span class="sp-mono">mix@example.org</span> at 14:02; it works once, for 24 hours.`,
|
||||
)
|
||||
// Account is Profile and Password; where a domain's reports go is the
|
||||
// domain's own setting.
|
||||
pageLacks(t, "Account", out, `send_log_retention_days`, `/account/dmarc`, `dmarc_default`, `<h2>DMARC reports</h2>`,
|
||||
`Report authorization`, `default report address`, `id="dmarc"`)
|
||||
// No form asks for another's fields: two forms, two submit buttons.
|
||||
if got := strings.Count(out, `<form class="sp-form" method="post"`); got != 2 {
|
||||
// No form asks for another's fields: the profile, the password and,
|
||||
// while the address is not confirmed, its link again.
|
||||
if got := strings.Count(out, `<form class="sp-form`); got != 3 {
|
||||
t.Errorf("Account has %d forms, want 2", got)
|
||||
}
|
||||
// Two equal columns, one box each.
|
||||
|
||||
@@ -231,9 +231,12 @@ type UserForm struct {
|
||||
Submit string
|
||||
CancelHref string
|
||||
|
||||
Account Box
|
||||
Username string
|
||||
Email string
|
||||
Account Box
|
||||
Username string
|
||||
Email string
|
||||
// EmailTag says whether the stored address is confirmed (nil without
|
||||
// one).
|
||||
EmailTag *Tag
|
||||
NewUser bool
|
||||
PasswordMin int
|
||||
Roles []Option
|
||||
@@ -420,3 +423,16 @@ func names(list []string) Text {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// WithEmailState tags the user's stored address confirmed or not; nothing
|
||||
// for a user without one.
|
||||
func (p *UserForm) WithEmailState(email string, confirmed bool) *UserForm {
|
||||
switch {
|
||||
case email == "":
|
||||
case confirmed:
|
||||
p.EmailTag = &Tag{Status: "ok", Label: "confirmed"}
|
||||
default:
|
||||
p.EmailTag = &Tag{Status: "warn", Label: "not confirmed"}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ func TestUserFormShowsWhatIsStoredUnderTheHandlersFieldNames(t *testing.T) {
|
||||
pageHas(t, "User", out,
|
||||
`<h1 class="title is-3">acme-ops</h1>`, `href="/server/users">Users</a>`, `href="/server/users/3/delete"`,
|
||||
`<form class="sp-form" method="post" action="/server/users/3">`,
|
||||
`name="username" value="acme-ops"`, `<label class="label" for="email">E-mail</label>`,
|
||||
`name="username" value="acme-ops"`, `<label class="label" for="email">E-mail <span class="tag is-success is-light">confirmed</span></label>`,
|
||||
`<input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off">`,
|
||||
`name="password" type="password" autocomplete="new-password">`,
|
||||
`Leave empty to keep the current one.`,
|
||||
@@ -316,8 +316,9 @@ func TestEveryHelpLinkLandsOnATopic(t *testing.T) {
|
||||
// change it under Account, and it can receive DMARC reports if they choose.
|
||||
func TestUserFormEmailHelpSaysOnlyWhatIsTrue(t *testing.T) {
|
||||
out := renderSignedIn(t, "user", userFixture())
|
||||
pageHas(t, "User", out, `The user's own address. They can change it under Account.`)
|
||||
pageLacks(t, "User", out, `notification`, `reset`)
|
||||
pageHas(t, "User", out, `The user's own address. They can change it under Account. A new one is sent a link to confirm it; until it is confirmed no reset link goes to it.`,
|
||||
`E-mail <span class="tag is-success is-light">confirmed</span>`)
|
||||
pageLacks(t, "User", out, `notification`)
|
||||
}
|
||||
|
||||
// A domain's report address belongs to the domain, not to a user: deleting a
|
||||
|
||||
@@ -16,6 +16,9 @@ type Login struct {
|
||||
Host string
|
||||
SetupHint bool
|
||||
Error string
|
||||
// Info is the result of what brought the person back here - a reset link
|
||||
// asked for, a password set from one - shown as a success flash.
|
||||
Info string
|
||||
}
|
||||
|
||||
// NewLogin returns the sign-in page's data. host and formErr may be empty.
|
||||
@@ -33,6 +36,94 @@ func NewLoginSetupHint(host string) *Login {
|
||||
// Error is set.
|
||||
func (l *Login) Notice() Flash { return Flash{Error: true, Text: Plain(l.Error)} }
|
||||
|
||||
// WithInfo sets the success flash.
|
||||
func (l *Login) WithInfo(info string) *Login {
|
||||
l.Info = info
|
||||
return l
|
||||
}
|
||||
|
||||
// InfoNotice is Info as the flash partial's input.
|
||||
func (l *Login) InfoNotice() Flash { return Flash{Text: Plain(l.Info)} }
|
||||
|
||||
// Reset is the page that asks for a reset link (templates/login_reset.html,
|
||||
// mockup login-reset.html): a username, and what will happen. Error is a
|
||||
// refusal, or a link that no longer works.
|
||||
type Reset struct {
|
||||
Meta
|
||||
Host string
|
||||
Error string
|
||||
}
|
||||
|
||||
// NewReset returns the reset request page's data.
|
||||
func NewReset(host, formErr string) *Reset {
|
||||
return &Reset{Meta: Meta{Title: "Reset password"}, Host: host, Error: formErr}
|
||||
}
|
||||
|
||||
// Notice is the page's error as the flash partial's input.
|
||||
func (p *Reset) Notice() Flash { return Flash{Error: true, Text: Plain(p.Error)} }
|
||||
|
||||
// ResetSet is the page a reset link opens (templates/login_reset_set.html,
|
||||
// mockup login-reset-set.html): the new password, for User, posted back to
|
||||
// the link's own path.
|
||||
type ResetSet struct {
|
||||
Meta
|
||||
Host string
|
||||
User string
|
||||
Token string
|
||||
Error string
|
||||
}
|
||||
|
||||
// NewResetSet returns the new-password page's data.
|
||||
func NewResetSet(host, user, token, formErr string) *ResetSet {
|
||||
return &ResetSet{Meta: Meta{Title: "New password"}, Host: host, User: user, Token: token, Error: formErr}
|
||||
}
|
||||
|
||||
// Kicker is the host and the account the password is for.
|
||||
func (p *ResetSet) Kicker() string { return kickerOf(p.Host, p.User) }
|
||||
|
||||
// Notice is the page's error as the flash partial's input.
|
||||
func (p *ResetSet) Notice() Flash { return Flash{Error: true, Text: Plain(p.Error)} }
|
||||
|
||||
// Confirm is the page a confirmation link opens (templates/confirm_email.html,
|
||||
// mockup confirm-email.html): the address and a button - a mail scanner
|
||||
// opens a link, so the GET changes nothing. Done is the page after the
|
||||
// button; Error a link that does not work, with no button.
|
||||
type Confirm struct {
|
||||
Meta
|
||||
Host string
|
||||
User string
|
||||
Email string
|
||||
Token string
|
||||
Error string
|
||||
Done bool
|
||||
// SignIn is the error of a link opened without its account's session:
|
||||
// the page points at sign-in.
|
||||
SignIn bool
|
||||
}
|
||||
|
||||
// NewConfirm returns the confirmation page's data.
|
||||
func NewConfirm(host, user, email, token string) *Confirm {
|
||||
return &Confirm{Meta: Meta{Title: "Confirm e-mail"}, Host: host, User: user, Email: email, Token: token}
|
||||
}
|
||||
|
||||
// Kicker is the host and the account the address is of.
|
||||
func (p *Confirm) Kicker() string { return kickerOf(p.Host, p.User) }
|
||||
|
||||
// Notice is the page's error as the flash partial's input.
|
||||
func (p *Confirm) Notice() Flash { return Flash{Error: true, Text: Plain(p.Error)} }
|
||||
|
||||
// kickerOf joins the host and an account's name, either of which may be
|
||||
// unknown.
|
||||
func kickerOf(host, user string) string {
|
||||
switch {
|
||||
case host == "":
|
||||
return user
|
||||
case user == "":
|
||||
return host
|
||||
}
|
||||
return host + " · " + user
|
||||
}
|
||||
|
||||
// Setup is the data of the first-run page that creates the administrator
|
||||
// (templates/setup.html), reached by the one-time link /setup/<Token>. Token is
|
||||
// the path segment the form posts back to; Error is the refusal of the last
|
||||
|
||||
@@ -12,12 +12,18 @@
|
||||
{{template "body_open"}}
|
||||
<form class="sp-form" method="post" action="/account/profile">
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="email" autocapitalize="none" spellcheck="false"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="label" for="email">E-mail{{with .EmailTag}} {{template "tag" .}}{{end}}</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="email" autocapitalize="none" spellcheck="false"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. Saving a new one sends it a link to confirm it; once confirmed it is where a link to reset your password can go. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="label" for="profile_password">Current password</label><div class="control"><input class="input" id="profile_password" name="current_password" type="password" autocomplete="current-password"></div>
|
||||
<p class="help">Needed only to change the e-mail.</p></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify"{{if .Notify}} checked{{end}}> Send me notifications</label>
|
||||
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="/help#notifications">What is sent and when</a></p></div>
|
||||
<button type="submit" class="button is-primary">Save profile</button>
|
||||
</form>
|
||||
{{with .Resend}}<form class="sp-form mt-4" method="post" action="/account/email/confirm">
|
||||
<p class="help mb-3">{{if .Sent}}A link was sent to <span class="sp-mono">{{.Email}}</span> at {{.Sent}}; it works once, for 24 hours.{{else}}No link is waiting: send one to confirm <span class="sp-mono">{{.Email}}</span>.{{end}}</p>
|
||||
<button type="submit" class="button">Send the link again</button>
|
||||
</form>{{end}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{{/* The page a confirmation link opens (mockup confirm-email.html): the
|
||||
address and a button, since a mail scanner opens links and a GET must
|
||||
change nothing. After the button, the same column says it is done; a link
|
||||
that does not work says so and has no button. */}}
|
||||
{{define "content"}}
|
||||
{{if .Done}}
|
||||
<div class="sp-form">
|
||||
{{with .Kicker}}<p>{{template "kicker" .}}</p>{{end}}
|
||||
<h1 class="title is-3">E-mail confirmed</h1>
|
||||
<p class="sp-muted mb-4"><span class="sp-mono">{{.Email}}</span> is the confirmed address of this account.</p>
|
||||
<p><a href="/account">Account</a></p>
|
||||
</div>
|
||||
{{else if .Error}}
|
||||
<div class="sp-form">
|
||||
{{with .Kicker}}<p>{{template "kicker" .}}</p>{{end}}
|
||||
<h1 class="title is-3">Confirm e-mail</h1>
|
||||
{{template "flash" .Notice}}
|
||||
{{if .SignIn}}<p><a href="/login">Sign in</a></p>{{else}}<p><a href="/account">Account</a></p>{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<form class="sp-form" method="post" action="/confirm/{{.Token}}">
|
||||
{{with .Kicker}}<p>{{template "kicker" .}}</p>{{end}}
|
||||
<h1 class="title is-3">Confirm e-mail</h1>
|
||||
<p class="sp-muted mb-4"><span class="sp-mono">{{.Email}}</span> becomes the confirmed address of this account: where a link to reset its password can be sent.</p>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Confirm</button></div>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -14,7 +14,7 @@
|
||||
<form class="sp-form" method="post" action="/login">
|
||||
{{if .Host}}<p>{{template "kicker" .Host}}</p>{{end}}
|
||||
<h1 class="title is-3">Sign in</h1>
|
||||
{{if .Error}}{{template "flash" .Notice}}{{end}}
|
||||
{{if .Error}}{{template "flash" .Notice}}{{else if .Info}}{{template "flash" .InfoNotice}}{{end}}
|
||||
<div class="field">
|
||||
<label class="label" for="username">Username</label>
|
||||
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" autofocus required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
|
||||
@@ -24,6 +24,7 @@
|
||||
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
|
||||
<p class="mt-4"><a href="/login/reset">Forgot your password?</a></p>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{{/* Asking for a reset link (mockup login-reset.html): the form column of the
|
||||
signed-out screen. The answer to the form is the sign-in page, the same
|
||||
for any name (mockup login-reset-sent.html). */}}
|
||||
{{define "content"}}
|
||||
<form class="sp-form" method="post" action="/login/reset">
|
||||
{{if .Host}}<p>{{template "kicker" .Host}}</p>{{end}}
|
||||
<h1 class="title is-3">Reset password</h1>
|
||||
<p class="sp-muted mb-4">A link to set a new password goes to the e-mail of the account, once that address is confirmed under Account. It works once, for 30 minutes.</p>
|
||||
{{if .Error}}{{template "flash" .Notice}}{{end}}
|
||||
<div class="field">
|
||||
<label class="label" for="username">Username</label>
|
||||
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" autofocus required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Send the link</button></div>
|
||||
<p class="mt-4"><a href="/login">Back to sign in</a></p>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{/* The page a reset link opens (mockup login-reset-set.html): the new
|
||||
password, posted back to the link's own path. */}}
|
||||
{{define "content"}}
|
||||
<form class="sp-form" method="post" action="/login/reset/{{.Token}}">
|
||||
{{with .Kicker}}<p>{{template "kicker" .}}</p>{{end}}
|
||||
<h1 class="title is-3">New password</h1>
|
||||
<p class="sp-muted mb-4">Setting it signs every session of this account out; sign in with it afterwards.</p>
|
||||
{{if .Error}}{{template "flash" .Notice}}{{end}}
|
||||
<div class="field">
|
||||
<label class="label" for="new_password">New password</label>
|
||||
<div class="control has-icons-left"><input class="input" id="new_password" name="new_password" type="password" autocomplete="new-password" minlength="12" autofocus required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="new_password_confirm">Repeat</label>
|
||||
<div class="control has-icons-left"><input class="input" id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password" minlength="12" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
|
||||
</div>
|
||||
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Set password</button></div>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -15,8 +15,8 @@
|
||||
{{template "box_open" .Account}}
|
||||
{{template "body_open"}}
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="off"></div>
|
||||
<p class="help">The user's own address. They can change it under Account.</p></div>
|
||||
<div class="field"><label class="label" for="email">E-mail{{with .EmailTag}} {{template "tag" .}}{{end}}</label><div class="control"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="off"></div>
|
||||
<p class="help">The user's own address. They can change it under Account. A new one is sent a link to confirm it; until it is confirmed no reset link goes to it.</p></div>
|
||||
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"{{if .NewUser}} required{{end}}></div>
|
||||
<p class="help">{{if .NewUser}}At least {{.PasswordMin}} characters.{{else}}Leave empty to keep the current one.{{end}}</p></div>
|
||||
<div class="field"><label class="label" for="role">Role</label>
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
|
||||
"Source",
|
||||
"No warranty",
|
||||
}
|
||||
signedOut := map[string]bool{"login": true, "setup": true}
|
||||
signedOut := map[string]bool{"login": true, "setup": true, "login-reset": true, "login-reset-set": true, "confirm-email": true}
|
||||
for name, fixture := range pageFixtures {
|
||||
rec := httptest.NewRecorder()
|
||||
engine.Render(rec, http.StatusOK, name, fixture())
|
||||
|
||||
@@ -35,6 +35,9 @@ type Engine struct {
|
||||
var pageFiles = map[string][]string{
|
||||
"setup": {"templates/setup.html"},
|
||||
"login": {"templates/login.html"},
|
||||
"login-reset": {"templates/login_reset.html"},
|
||||
"login-reset-set": {"templates/login_reset_set.html"},
|
||||
"confirm-email": {"templates/confirm_email.html"},
|
||||
"overview": {"templates/overview.html", "templates/overview_cards.html"},
|
||||
"health": {"templates/health.html", "templates/health_body.html"},
|
||||
"account": {"templates/account.html"},
|
||||
|
||||
Reference in New Issue
Block a user