feat: migrate from username to email-only authentication #270
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Email-Only Authentication Migration
Context
Currently users log in with a username. The goal is to replace username with email as the sole login identifier — simpler for family members who don't need to remember a separate handle.
Risk: Existing Users Without Email
The V44 migration sets
email NOT NULL. It will fail if anyusersrow has a null email. The migration should include a pre-checkDOblock that aborts with a clear message if null emails exist, forcing an admin cleanup first.What Needs to Change
Database — V44 migration
Backend
AppUserentity — removeusernamefield; email becomes the identity fieldUserRepository— replacefindByUsername(String username)withfindByEmail(String email)CustomUserDetailsService—loadUserByUsername(String email)(interface method name is fixed by Spring Security); query by email; build theUserobject using email as the principal nameSecurityConfig— add.usernameParameter("email")to form login config; HTTP Basic is unaffected (Spring passes whatever string is authenticated)CreateUserRequestDTO — removeusername, makeemailrequired/non-nullUserService— remove username handling fromcreateUser()andupdateFromRequest(); ensure email uniqueness is enforced viaDataIntegrityViolationExceptioncatch (UNIQUE constraint already exists)Frontend
/loginpage — change inputname="username"→name="email"; update credential encoding:btoa("email:password")instead ofbtoa("username:password")user.usernamedisplay — replace withuser.emailor${user.firstName} ${user.lastName}as appropriateRegistration (issue #269)
RegisterRequestDTO — nousernamefield; email is the login credential/registerform — remove username input; email pre-filled from invite token becomes requiredVerification
./mvnw flyway:migrate— verify it succeeds/loginwith email + password → verify successcurl -u user@example.com:password /api/users/mecd backend && ./mvnw test👨💻 Felix Brandt — Senior Fullstack Developer
Observations
CustomUserDetailsServiceTesthas 5 tests that mockfindByUsername()and assertdetails.getUsername(). Every single one needs rewriting to usefindByEmail()and assert the email as the principal name.UserControllercallsfindByUsername(authentication.getName())in 3 endpoints (GET /me,PUT /me,POST /me/password). After migrationauthentication.getName()returns the email — these must switch tofindByEmail().AppUserRepository.searchByNameOrUsername— the JPQL query referencesLOWER(u.username). That column is being dropped; this query will throw at runtime if not updated before V44 ships.UserService.createUserOrUpdate()— the upsert key isfindByUsername(). Switching tofindByEmail()as the upsert key changes upsert semantics and needs an explicit test covering "update an existing user by email."AppUser.updateFromRequest()still setsthis.username— that line must be removed.NotificationRepositoryTestcreates users with.username("userA")and no.email(). Once V44 enforcesNOT NULL, any Testcontainers integration test that saves these users will blow up.Recommendations
CustomUserDetailsServiceTesttests before touchingCustomUserDetailsService— TDD red/green keeps the auth layer honest.NotificationRepositoryTestand any other integration test that creates bareAppUserbuilders in the same PR as the migration — do not let them slip to a follow-up.searchByNameOrUsernameshould pivot to searchingemail,first_name,last_name— update the JPQL and its test in this issue's scope, not a separate one.🏗️ Markus Keller — Senior Application Architect
Observations
RegisterRequesthas nousernamefield — that is only safe once the auth layer no longer expects one. Implementing them in the wrong order means registration ships with a broken auth flow.DOblock is exactly right — consistent with the project's constraint-first approach (see V30, V18). Do not weaken it.updateProfile()currently allows settingemailtonull(user.setEmail(null)). Once V44 enforcesNOT NULLat the DB layer, this produces a constraint violation at persist time instead of a cleanDomainException. That gap must be closed before the migration runs — add an explicit null/blank check inUserServiceand throwDomainException.badRequest(...).searchByNameOrUsernameis a cross-concern: the JPQL references theusernamecolumn being dropped. Updating it belongs in this issue's scope.Recommendations
@NotBlank/@Emailvalidation on email inCreateUserRequestnow — email is becoming the identity field, it should be validated at the DTO layer, not just the DB layer.updateProfile()null-email gap is the only pre-migration safety fix that can't be caught by theDOblock — make it explicit in the implementation checklist.🔒 Nora Steiner — Security Engineer
Observations
userid ":" password, whereuseridmust not contain a colon. An email likeuser:name@example.comwould split incorrectly inbtoa("email:password"). This is a real, exploitable parsing bug — not theoretical.auth_tokencookies storeBasic base64(username:password). After V44 ships, any cached cookie encoding the old username will failloadUserByUsernamesilently — the user will be redirected to login on next request. This is the correct and desired behavior, but the migration checklist should state it explicitly so it is not treated as a bug.CustomUserDetailsServicelogs in a warning path. Confirm the log statement uses SLF4J{}parameterized form (not string concatenation) — once it logs email addresses, concatenation would reopen a log-injection vector. Parameterized logging is fine.Recommendations
@Pattern(regexp = "^[^:]+$")). Apply it to bothCreateUserRequestand any profile update DTO. This is the only safe fix for the Basic Auth parsing issue.RateLimitInterceptor) should also cover the login endpoint — out of scope here, but log it as a follow-up.🧪 Sara Holt — QA Engineer
Observations
updateProfile()null email: No test verifies thatPUT /api/users/mewith a null email body fails cleanly after V44. Without it, the constraint violation surfaces as a 500 instead of a 400.NotificationRepositoryTest: CreatesAppUser.builder().username("userA").password("pw").build()with no email. Once V44 runs on Testcontainers, this will throw aNOT NULLconstraint violation. All integration test fixtures creating users must include.email("...").UserControllerTest: Any assertion onjsonPath("$.username")will fail once the field is removed from the response. Tests must be updated to assert onemailinstead.Recommendations
email, nousernamefield."AppUserbuilder usages in Testcontainers tests in the same PR as the migration — this is not optional cleanup.⚙️ Tobias Wendt — DevOps Engineer
Observations
usernamecolumn means any running backend instance compiled against the old schema will fail immediately on any query that SELECTsusername. A single-step migration + deploy has a brief window where the old pod and the new pod coexist, causing errors.NotificationRepositoryTestand any Testcontainers test that creates users without.email()as soon as V44 is added — which is actually the correct behavior. But it means those tests must be fixed in the same PR, not after.DOblock prevents running against bad data, but there is no down-migration. Apg_dumpmust be taken and verified immediately before V44 runs in production.Recommendations
email NOT NULL, deploy new JAR; Phase 2 — dropusernamecolumn once all instances run new code. For a single-node family app this may be overkill, but the option should be an explicit decision.pg_dumpbackup" as step 0 of the verification checklist — it is currently missing.NotificationRepositoryTestand related tests are fixed in the same branch.🎨 Leonie Voss — UX & Accessibility Designer
Observations
<input type="text">→<input type="email">: The login form andAccountSection.sveltecurrently usetype="text"for the username field. Switching totype="email"gives mobile users the correct keyboard layout, enables browser email autofill, and provides basic format validation for free.autocompleteattribute: The login form currently hasautocomplete="username". Changing toautocomplete="email"is required — without it, password managers and browser autofill will not offer the correct credentials after migration.m.login_label_username()and the hardcoded German string'Bitte Benutzername und Passwort eingeben.'in+page.server.tsboth reference "Benutzername." Both must become email-aware. The error string bypasses the Paraglide system entirely — it should use a translation key, not a hardcoded string.m.admin_col_login()labels the username input in the admin user form. After the username field is removed, the email field label should bem.admin_label_email()— consistent with the rest of the form.Recommendations
type="email"withautocomplete="email".login_label_emailandlogin_error_missing_credentialsinde.json,en.json,es.json— and remove the hardcoded German string.🗳️ Decision Queue — Action Required
4 decisions need your input before implementation starts.
Architecture
searchByNameOrUsernamereplacement — the JPQL query references theusernamecolumn being dropped. Should it pivot to searchingemailonly, or searchemail + first_name + last_name? The latter is more useful but changes the search semantics. (Raised by: Felix, Markus)Security
a:b@example.comwould break login. Add a@Pattern(regexp = "^[^:]+$")validator on all email fields. This is a hard recommendation given the auth mechanism — confirm it's in scope for this issue. (Raised by: Nora)Backend
updateProfile()email nullability —PUT /api/users/mecurrently allows setting email to null. Once V44 runs, this produces a DB constraint error (500) instead of a clean 400. Must be fixed before the migration. Confirm this belongs in this issue's PR scope. (Raised by: Markus, Sara)🏗️ Markus Keller — Architect Review
Worked through all 4 open decisions from the Decision Queue. All resolved.
Resolved
searchByNameOrUsernamereplacement — Pivot to searchingemail + firstName + lastName. Email-only is too narrow for an admin search; the added columns are already on the entity and the query is no more complex.Migration strategy — Single-step migration (make
email NOT NULL+ dropusernamein one V44 file). This app runs single-node Docker Compose — there is no rolling deploy coexistence window, only a brief maintenance window. Document this explicitly in the PR description as a conscious decision.Colon-in-email validation — In scope for this PR. Add
@Pattern(regexp = "^[^:]+$")to all email input fields (CreateUserRequest, profile update DTO). Email is becoming the identity field; it must be validated correctly from day one. Closes the HTTP Basic Auth parsing bug Nora flagged.updateProfile()null email guard — In scope for this PR. Add an explicit null/blank check inUserService.updateProfile()throwingDomainException.badRequest(...)before the save. The V44DOblock pre-check cannot catch this runtime gap — it must land before the migration ships.Overall read
The issue is well-scoped and the pre-check
DOblock is the right approach — consistent with how V30 and V18 handle constraint enforcement. The four decisions above are the only open items; once they are reflected in the implementation checklist, this is ready to implement.