The web's password forms read the server's minimum length

The server's MIN_PASSWORD_LEN was 8, and the web wrote 8 out five times: two
checks and three placeholders. The constant moves beside the other policy numbers
in settings.py (auth.py imports it), /api/config serves it as
min_password_length, and the config store hands it to Register, Reset and
Account. 8 stays only as the fallback until the config answers.

DRY pass #2, batch 3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:31:22 -04:00
co-authored by Claude Opus 5.5
parent 1c4bf56058
commit bfe2ed783f
6 changed files with 20 additions and 9 deletions
+7
View File
@@ -46,6 +46,9 @@ export interface PublicConfig {
// Whether this server can email a reset link, so sign-in offers "Forgot password?" // Whether this server can email a reset link, so sign-in offers "Forgot password?"
// (#5266). Absent on an older server and on the offline desktop: no. // (#5266). Absent on an older server and on the offline desktop: no.
password_reset_by_email?: boolean; password_reset_by_email?: boolean;
// The shortest password an account may have (the server's MIN_PASSWORD_LEN).
// Absent on an older server and on the offline desktop, which has no accounts.
min_password_length?: number;
// Every client this server holds, keyed by platform id. Absent on a server that // Every client this server holds, keyed by platform id. Absent on a server that
// holds none, and absent on the desktop's own offline config — the Tauri build // holds none, and absent on the desktop's own offline config — the Tauri build
// answers `config_get` locally and has no clients to hand out. // answers `config_get` locally and has no clients to hand out.
@@ -66,6 +69,8 @@ export const useConfigStore = defineStore("config", () => {
// that never had the field, both correctly offer no downloads. // that never had the field, both correctly offer no downloads.
const clients = ref<Record<string, ClientRelease>>({}); const clients = ref<Record<string, ClientRelease>>({});
const passwordResetByEmail = ref(false); const passwordResetByEmail = ref(false);
// The server's own number; 8 only until /api/config answers, or if it never does.
const minPasswordLength = ref(8);
const loaded = ref(false); const loaded = ref(false);
async function load(): Promise<void> { async function load(): Promise<void> {
@@ -79,6 +84,7 @@ export const useConfigStore = defineStore("config", () => {
trashRetentionDays.value = cfg.trash_retention_days ?? 30; trashRetentionDays.value = cfg.trash_retention_days ?? 30;
clients.value = cfg.clients ?? {}; clients.value = cfg.clients ?? {};
passwordResetByEmail.value = cfg.password_reset_by_email ?? false; passwordResetByEmail.value = cfg.password_reset_by_email ?? false;
minPasswordLength.value = cfg.min_password_length ?? 8;
} catch { } catch {
// Keep defaults if the config endpoint is unreachable. // Keep defaults if the config endpoint is unreachable.
} finally { } finally {
@@ -99,6 +105,7 @@ export const useConfigStore = defineStore("config", () => {
trashRetentionDays, trashRetentionDays,
clients, clients,
passwordResetByEmail, passwordResetByEmail,
minPasswordLength,
loaded, loaded,
load, load,
reload, reload,
+1 -1
View File
@@ -264,7 +264,7 @@ onMounted(() => {
type="password" type="password"
label="New password" label="New password"
autocomplete="new-password" autocomplete="new-password"
placeholder="At least 8 characters" :placeholder="`At least ${config.minPasswordLength} characters`"
:error="passwordError" :error="passwordError"
required required
/> />
+3 -3
View File
@@ -24,8 +24,8 @@ const loading = ref(false);
async function submit() { async function submit() {
error.value = ""; error.value = "";
if (password.value.length < 8) { if (password.value.length < config.minPasswordLength) {
error.value = "Password must be at least 8 characters."; error.value = `Password must be at least ${config.minPasswordLength} characters.`;
return; return;
} }
loading.value = true; loading.value = true;
@@ -80,7 +80,7 @@ async function submit() {
label="Password" label="Password"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
placeholder="At least 8 characters" :placeholder="`At least ${config.minPasswordLength} characters`"
required required
/> />
<p <p
+3 -3
View File
@@ -25,8 +25,8 @@ const loading = ref(false);
async function submit() { async function submit() {
error.value = ""; error.value = "";
if (password.value.length < 8) { if (password.value.length < config.minPasswordLength) {
error.value = "Password must be at least 8 characters."; error.value = `Password must be at least ${config.minPasswordLength} characters.`;
return; return;
} }
if (password.value !== confirm.value) { if (password.value !== confirm.value) {
@@ -77,7 +77,7 @@ async function submit() {
label="New password" label="New password"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
placeholder="At least 8 characters" :placeholder="`At least ${config.minPasswordLength} characters`"
required required
/> />
<BaseInput <BaseInput
+1 -2
View File
@@ -26,7 +26,7 @@ from .ratelimit import (
sign_in_by_address, sign_in_by_address,
) )
from .security import dummy_verify, generate_token, hash_password, hash_token, verify_password from .security import dummy_verify, generate_token, hash_password, hash_token, verify_password
from .settings import get_setting, mail_configured, set_settings from .settings import MIN_PASSWORD_LEN, get_setting, mail_configured, set_settings
from .storage import storage_limit, storage_used from .storage import storage_limit, storage_used
bp = Blueprint("auth", __name__, url_prefix="/api/auth") bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@@ -46,7 +46,6 @@ SESSION_KEY = "user_id"
# signed cookies in other people's browsers. A cookie from before epochs existed has # signed cookies in other people's browsers. A cookie from before epochs existed has
# no key and reads as 0, the epoch every account started at. # no key and reads as 0, the epoch every account started at.
EPOCH_KEY = "epoch" EPOCH_KEY = "epoch"
MIN_PASSWORD_LEN = 8
# The first account can only be created this long after the server starts (family # The first account can only be created this long after the server starts (family
# idea #5105, practice 8). Without it, a fresh server on a public address belongs to # idea #5105, practice 8). Without it, a fresh server on a public address belongs to
+5
View File
@@ -16,6 +16,10 @@ SettingType = Literal["string", "bool", "int"]
# attachment an admin may allow is a megabyte under it. # attachment an admin may allow is a megabyte under it.
MAX_BODY_MB = 64 MAX_BODY_MB = 64
# The shortest password an account may have. Enforced by auth.py, and served in the
# public config so the web's forms say the same number before a round-trip does.
MIN_PASSWORD_LEN = 8
@dataclass(frozen=True) @dataclass(frozen=True)
class SettingDef: class SettingDef:
@@ -320,6 +324,7 @@ async def get_public_config(db) -> dict:
"trash_retention_days": await get_setting(db, "trash_retention_days"), "trash_retention_days": await get_setting(db, "trash_retention_days"),
# Whether the sign-in screen offers "Forgot password?" (#5266). # Whether the sign-in screen offers "Forgot password?" (#5266).
"password_reset_by_email": await mail_configured(db), "password_reset_by_email": await mail_configured(db),
"min_password_length": MIN_PASSWORD_LEN,
} }