Modified - Finalized Register behavior for admin-created notification accounts without altering the invitation flow.
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled

- 1 - I extended `POST /user/sign_up` in `routers/web/auth/auth.go` for existing active local accounts created by an admin when `username` and `email` match, while explicitly leaving pending admin invitations on their existing flow.
- 2 - If password is correct, the user is now authenticated into that existing account; when `MustChangePassword` is enabled, the flow redirects directly to `/user/settings/change_password`, otherwise it follows the normal post-auth redirect.
- 3 - If password is incorrect, the flow now redirects to `/user/forgot_password?email=<email>` and shows a warning to use account recovery plus check Spam/Junk.
- 4 - I added the locale key `auth.admin_notify_recover_password_spam_hint` in both `options/locale/locale_en-US.json` and `options/locale/locale_ro-RO.json`.
- 5 - I added regression tests in `routers/web/auth/auth_test.go` for normal sign-in, forced change-password redirect, wrong-password recovery redirect, and a guard that the admin-invitation flow still redirects to `/user/invitation`.
This commit is contained in:
2026-05-12 00:48:37 +00:00
parent d81fdfc31f
commit 9ee45b3392
6 changed files with 195 additions and 0 deletions
+3
View File
@@ -73,6 +73,9 @@ For the structural baseline used by this context, see `./.ai-structure.md`.
- Persistent formatting rule: in `.codex-history.md`, project change entries must begin with the sequential numeric ID followed immediately by the timestamp in square brackets, using the format `N - [YYYY-MM-DD HH:MM:SS]`, for example `4 - [2026-04-16 03:11:08]`.
- Persistent formatting rule: the application version stored in `.codex-history.md` must use the real repository-derived application version format, without the `Version:` label, for example `v.1.27.0-dev-38-g4b334df6d4`.
- Persistent history rule: append new details to an existing `.codex-history.md` task only while they are consecutive follow-ups to the same problem. If unrelated tasks have already happened in between, record the new change as a new task even if it revisits the same area or feature.
- Persistent communication rule: after finishing a task, respond briefly and directly, without extra explanations unless there are problems/blockers.
- Persistent workflow rule: always enforce and re-check all rules defined in `./.codex-context.md` before and after implementing requested changes.
- Persistent history rule: `.codex-history.md` must keep only the final consolidated form of a task result, not intermediate attempts or failed correction steps.
## Useful Notes
+7
View File
@@ -714,3 +714,10 @@ Project Change ID[date-time] - application-version - Type - Summary:
- 2 - I implemented backend validation and persistence for all branding uploads in `routers/install/install.go` (expected type checks, 1 MB limit, square PNG with minimum 64x64) and save accepted overrides under `custom/public/assets/img/`.
- 3 - I completed the runtime behavior so uploaded branding files override built-in assets through layered serving, `logo.svg` is mirrored to `gitea.svg` for legacy lookups, post-install progress prefers a custom `loading.png`, and the shared-assets mode hides favicon fields while relabeling logo fields to `Logo & Favicon SVG/PNG`.
- 4 - I manually updated Romanian locale wording for the final branding texts and labels.
143 - [2026-05-12 00:02:10] - v1.27.0-dev-125-g1525c9c8ee - Type: Modified - Finalized Register behavior for admin-created notification accounts without altering the invitation flow.
- 1 - I extended `POST /user/sign_up` in `routers/web/auth/auth.go` for existing active local accounts created by an admin when `username` and `email` match, while explicitly leaving pending admin invitations on their existing flow.
- 2 - If password is correct, the user is now authenticated into that existing account; when `MustChangePassword` is enabled, the flow redirects directly to `/user/settings/change_password`, otherwise it follows the normal post-auth redirect.
- 3 - If password is incorrect, the flow now redirects to `/user/forgot_password?email=<email>` and shows a warning to use account recovery plus check Spam/Junk.
- 4 - I added the locale key `auth.admin_notify_recover_password_spam_hint` in both `options/locale/locale_en-US.json` and `options/locale/locale_ro-RO.json`.
- 5 - I added regression tests in `routers/web/auth/auth_test.go` for normal sign-in, forced change-password redirect, wrong-password recovery redirect, and a guard that the admin-invitation flow still redirects to `/user/invitation`.
+1
View File
@@ -457,6 +457,7 @@
"auth.invitation_mail_resend_not_available": "This account can no longer receive invitation emails.",
"auth.invitation_resend_prompt": "Did not receive the invitation email?",
"auth.invitation_resend_button": "Resend",
"auth.admin_notify_recover_password_spam_hint": "We couldn't verify your password. Please use account recovery and also check your Spam/Junk folder.",
"auth.account_request_blocked": "This account request has been blocked after %d unconfirmed attempts. An administrator must unblock it.",
"auth.account_request_rejected_signup": "This account request was rejected. An administrator must unblock it before the same email address can be used again.",
"auth.remember_me": "Remember This Device",
+1
View File
@@ -457,6 +457,7 @@
"auth.invitation_mail_resend_not_available": "Acest cont nu mai poate primi emailuri cu invitații.",
"auth.invitation_resend_prompt": "Nu ai primit emailul cu invitația?",
"auth.invitation_resend_button": "Retrimite",
"auth.admin_notify_recover_password_spam_hint": "Parola nu a putut fi verificată. Folosește recuperarea contului și verifică și folderul Spam/Junk.",
"auth.account_request_blocked": "Această cerere de cont a fost blocată după %d încercări neconfirmate. Un administrator trebuie să o deblocheze.",
"auth.account_request_rejected_signup": "Această cerere de cont a fost respinsă. Un administrator trebuie să o deblocheze înainte ca aceeași adresă de email să poată fi utilizată din nou.",
"auth.remember_me": "Reține acest dispozitiv",
+63
View File
@@ -551,6 +551,65 @@ func prepareSignUpPageData(ctx *context.Context) bool {
return true
}
func maybeHandleExistingAdminCreatedNotifyAccountOnSignup(ctx *context.Context, form *forms.RegisterForm) bool {
existingUserName := strings.TrimSpace(form.UserName)
if existingUserName == "" {
return false
}
existing, err := user_model.GetUserByName(ctx, existingUserName)
if err != nil {
if user_model.IsErrUserNotExist(err) {
return false
}
ctx.ServerError("GetUserByName", err)
return true
}
formEmail := strings.TrimSpace(form.Email)
if !strings.EqualFold(formEmail, existing.Email) || existing.LoginSource != 0 {
return false
}
// Apply only to admin-created local accounts, without changing the invite flow.
if _, err := user_model.GetUserSetting(ctx, existing.ID, user_model.SettingsKeyAdminInviteCreatedBy); err != nil {
if user_model.IsErrUserSettingIsNotExist(err) {
return false
}
ctx.ServerError("GetUserSetting", err)
return true
}
isPendingAdminInvite, err := user_model.IsPendingAdminInvite(ctx, existing)
if err != nil {
ctx.ServerError("IsPendingAdminInvite", err)
return true
}
if isPendingAdminInvite {
ctx.Redirect(setting.AppSubURL + "/user/invitation?email=" + url.QueryEscape(existing.Email))
return true
}
if !existing.IsActive {
return false
}
if existing.ValidatePassword(form.Password) {
handleSignInFull(ctx, existing, false)
if ctx.Written() {
return true
}
if existing.MustChangePassword {
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
return true
}
redirectAfterAuth(ctx)
return true
}
ctx.Flash.Warning(ctx.Tr("auth.admin_notify_recover_password_spam_hint"), true)
ctx.Redirect(setting.AppSubURL + "/user/forgot_password?email=" + url.QueryEscape(existing.Email))
return true
}
// SignUp render the register page
func SignUp(ctx *context.Context) {
if !prepareSignUpPageData(ctx) {
@@ -589,6 +648,10 @@ func SignUpPost(ctx *context.Context) {
return
}
if maybeHandleExistingAdminCreatedNotifyAccountOnSignup(ctx, form) {
return
}
context.VerifyCaptcha(ctx, tplSignUp, form)
if ctx.Written() {
return
+120
View File
@@ -330,6 +330,126 @@ func TestSignInPostPendingAdminInviteRedirectsToInvitationPrompt(t *testing.T) {
}
}
func TestSignUpPostPendingAdminInviteRedirectsToInvitationPrompt(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Service.DisableRegistration, false)()
defer test.MockVariableValue(&setting.Service.AllowOnlyExternalRegistration, false)()
user := &user_model.User{
Name: "signup-pending-admin-invite-user",
Email: "signup-pending-admin-invite-user@example.com",
Passwd: "password",
}
require.NoError(t, user_model.CreateUser(t.Context(), user, &user_model.Meta{}, &user_model.CreateUserOverwriteOptions{IsActive: optional.Some(false)}))
require.NoError(t, user_model.SetAdminInvitePending(t.Context(), user.ID, true))
require.NoError(t, user_model.SetUserSetting(t.Context(), user.ID, user_model.SettingsKeyAdminInviteCreatedBy, "1"))
form := &forms.RegisterForm{
UserName: user.Name,
Email: user.Email,
Password: "password",
Retype: "password",
}
ctx, resp := contexttest.MockContext(t, "POST /user/sign_up")
web.SetForm(ctx.Data, form)
SignUpPost(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
assert.Equal(t, "/user/invitation?email="+url.QueryEscape(user.Email), test.RedirectURL(resp))
}
func TestSignUpPostExistingAdminCreatedNotifyAccountSignsInNormally(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Service.DisableRegistration, false)()
defer test.MockVariableValue(&setting.Service.AllowOnlyExternalRegistration, false)()
user := &user_model.User{
Name: "signup-admin-created-notify-normal-user",
Email: "signup-admin-created-notify-normal-user@example.com",
Passwd: "password",
MustChangePassword: false,
}
require.NoError(t, user_model.CreateUser(t.Context(), user, &user_model.Meta{}))
require.NoError(t, user_model.SetUserSetting(t.Context(), user.ID, user_model.SettingsKeyAdminInviteCreatedBy, "1"))
form := &forms.RegisterForm{
UserName: user.Name,
Email: user.Email,
Password: "password",
Retype: "password",
}
sessionStore := session.NewMockMemStore("signup-admin-created-notify-normal")
ctx, resp := contexttest.MockContext(t, "POST /user/sign_up", contexttest.MockContextOption{SessionStore: sessionStore})
web.SetForm(ctx.Data, form)
SignUpPost(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
assert.Equal(t, "/", test.RedirectURL(resp))
assert.Equal(t, user.ID, sessionStore.Get(session.KeyUID))
}
func TestSignUpPostExistingAdminCreatedNotifyAccountRedirectsToChangePassword(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Service.DisableRegistration, false)()
defer test.MockVariableValue(&setting.Service.AllowOnlyExternalRegistration, false)()
user := &user_model.User{
Name: "signup-admin-created-notify-change-password-user",
Email: "signup-admin-created-notify-change-password-user@example.com",
Passwd: "password",
MustChangePassword: true,
}
require.NoError(t, user_model.CreateUser(t.Context(), user, &user_model.Meta{}))
require.NoError(t, user_model.SetUserSetting(t.Context(), user.ID, user_model.SettingsKeyAdminInviteCreatedBy, "1"))
form := &forms.RegisterForm{
UserName: user.Name,
Email: user.Email,
Password: "password",
Retype: "password",
}
sessionStore := session.NewMockMemStore("signup-admin-created-notify-change-password")
ctx, resp := contexttest.MockContext(t, "POST /user/sign_up", contexttest.MockContextOption{SessionStore: sessionStore})
web.SetForm(ctx.Data, form)
SignUpPost(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
assert.Equal(t, "/user/settings/change_password", test.RedirectURL(resp))
assert.Equal(t, user.ID, sessionStore.Get(session.KeyUID))
}
func TestSignUpPostExistingAdminCreatedNotifyAccountWrongPasswordRedirectsToRecover(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Service.DisableRegistration, false)()
defer test.MockVariableValue(&setting.Service.AllowOnlyExternalRegistration, false)()
user := &user_model.User{
Name: "signup-admin-created-notify-wrong-password-user",
Email: "signup-admin-created-notify-wrong-password-user@example.com",
Passwd: "password",
MustChangePassword: true,
}
require.NoError(t, user_model.CreateUser(t.Context(), user, &user_model.Meta{}))
require.NoError(t, user_model.SetUserSetting(t.Context(), user.ID, user_model.SettingsKeyAdminInviteCreatedBy, "1"))
form := &forms.RegisterForm{
UserName: user.Name,
Email: user.Email,
Password: "wrong-password",
Retype: "wrong-password",
}
ctx, resp := contexttest.MockContext(t, "POST /user/sign_up")
web.SetForm(ctx.Data, form)
SignUpPost(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
assert.Equal(t, "/user/forgot_password?email="+url.QueryEscape(user.Email), test.RedirectURL(resp))
}
func TestSignInPostLegacyPendingAdminInviteRedirectsToInvitationPrompt(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Service.EnablePasswordSignInForm, true)()