Username sign-in & optional email
Let users sign in with a username, allow accounts without an email address, and handle recovery for shared or missing emails.
#Username sign-in & optional email
By default EmuView accounts are identified by a unique email address. Instances migrating from systems like WordPress or phpBB often have users who share one email across several accounts (families) or who have no email at all. Username sign-in solves both: usernames become an additional unique login identifier, and email becomes optional.
Both behaviors are opt-in settings under Settings → General → Authentication:
| Setting | Default | Effect |
|---|---|---|
| Username Sign-In | Disabled | Users can sign in with a username as well as an email. Sign-up and user import accept a username. |
| Require Email at Signup | Enabled | When disabled (requires Username Sign-In), accounts can be created with only a username. |
| Reserved Usernames | — | Instance-specific names nobody may register, merged with the built-in list (admin, root, support, …). |
Changes take effect immediately — no redeploy or restart. Disabling Username Sign-In keeps all usernames in place; re-enabling restores them.
Accounts created without an email can only sign in by username. Disabling Username Sign-In locks them out until it is re-enabled — the settings page warns you with a count before you do this.
#How sign-in works
One identifier field serves both cases: usernames can never contain @, so anything with an @ is treated as an email and anything without one as a username.
// SDK — pick whichever form fits your UI
await sdk.auth.signIn({ email: 'user@example.com', password });
await sdk.auth.signIn({ username: 'mattsmith', password });
await sdk.auth.signIn({ identifier: formValue, password }); // routes automatically
Check what the instance supports before rendering your login form:
const caps = await sdk.auth.getCapabilities();
// { usernameEnabled: boolean, requireEmail: boolean, socialProviders: {...} }
#Username rules
- 3–30 characters, letters/digits/underscore/dot (
[a-zA-Z0-9_.]) - Case-insensitive: stored lowercase, the typed form is kept for display
- Reserved names (built-in + the Reserved Usernames setting) are rejected
- Usernames are changed by admins only (Users → edit)
Availability check (shares the strict auth rate limit — call on blur/submit, not per keystroke):
const free = await sdk.auth.isUsernameAvailable('newname');
#Accounts without an email
When Require Email at Signup is disabled, sign-up and import accept a username with no email. Internally the account holds a non-routable placeholder address (…@noemail.invalid) that satisfies the unique-email requirement; it is never displayed and never mailed. API responses mask it (email: null), and the SDK exports isPlaceholderEmail() if you handle raw auth responses.
A real — possibly shared — address can be stored as the account's contact email, which is outbound-only: it is used for password resets and nothing else, and it does not need to be unique.
#Password recovery
Reset requests accept an email or a username:
await sdk.auth.requestPasswordReset({ identifier: 'mattsmith', redirectTo: '/reset' });
- The response is always a generic success (no account enumeration).
- Username-based accounts get the reset mail at their contact email; because that address may be shared by a family, the email always names the account it resets.
- Magic links remain email-only — placeholder addresses are never mailed.
- Accounts with no reachable email are recovered by an admin: Users → edit → Issue one-time reset link creates a 1-hour reset URL (audit-logged as
user.recovery_link_issued) to pass on out-of-band.
#Importing users with shared or missing emails
POST /api/v1/users/import accepts an optional username per row. With Username Sign-In enabled:
| Row | Result |
|---|---|
| Unique email (± username) | Imported normally |
| Duplicate email + username | Imported: real address becomes the contact email, login slot gets a placeholder |
| No email + username (Require Email off) | Imported as a username-only account |
| Duplicate email, no username | Error already_exists (unchanged) |
| Duplicate username | Error username_exists |
Imported legacy passwords (WordPress/phpBB) keep working exactly as before — the lazy-rehash migration is identifier-agnostic, so first sign-in by username upgrades the hash the same way sign-in by email does.
#Security notes
- Sign-in errors and timings do not distinguish unknown-username from wrong-password.
- All auth endpoints share a per-IP rate limit (Settings → Security → Auth Rate Limit, default 5/min).
is-username-availableis deliberately under the same budget since it can enumerate usernames. emailVerifiednever gates access in EmuView; placeholder addresses are always unverified.