From 5cc71c3050d5d040c7106d8242dacf5ce2377777 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 16 Oct 2026 13:41:03 -0400 Subject: [PATCH] Settings: tabs instead of one long page, and a review pass (#5916) Settings was every registry group, then invites, accounts, groups and activity, in one scroll. It is now six tabs held in ?tab=: - General: General, Notes, Attachments, Links. - People: Access, Invites, Accounts, Groups. - Email, with a line saying whether email is on and what it still needs. - Security. - Activity. A registry group that no tab names lands on General, so it can't go missing. Save saves only the current tab. Edits waiting on other tabs keep their values and mark their tab with a dot. Leaving the page with unsaved edits asks first. Review fixes: - On a phone, a field stacks under its label instead of overflowing beside it. - Descriptions are easier to read. - The SMTP encryption choice shows readable names (choice_labels). - The test-email button uses .btn-sm and is disabled while email is off. - The accounts list is headed Accounts, not People, now that People is a tab. - The docs point at the new paths. Co-Authored-By: Claude Opus 5.5 --- docs/public-hosting.md | 6 +- frontend/src/adapters/repo.ts | 2 + frontend/src/components/AccountList.vue | 2 +- frontend/src/views/SettingsView.vue | 299 +++++++++++++++++------- src/inkwell/settings.py | 5 + src/inkwell/storage.py | 2 +- tests/test_settings.py | 8 + 7 files changed, 241 insertions(+), 83 deletions(-) diff --git a/docs/public-hosting.md b/docs/public-hosting.md index f5b6716..11ca407 100644 --- a/docs/public-hosting.md +++ b/docs/public-hosting.md @@ -23,10 +23,10 @@ matters. **Instances that predate this still need one manual flip.** The close fires when the first account is created, so a server whose admin already existed keeps whatever `allow_registration` was set to — which was **on** by default. Check **Settings → -Access → Allow new registrations** before exposing an instance you have been running +People → Allow new registrations** before exposing an instance you have been running on a LAN. -To let someone else in, send them an invite: **Settings → Invites** makes a link that +To let someone else in, send them an invite: **Settings → People → Invites** makes a link that works once, expires (a week by default), and can be pinned to one email address. It lets that one person register while registration stays closed. @@ -146,7 +146,7 @@ address can be sent. ## Storage per account Each account may store up to `storage_quota_gb` of attachments (Settings → -Attachments, 5 GB by default; 0 means no limit). Admins have no limit. Trashed notes +General → Attachments, 5 GB by default; 0 means no limit). Admins have no limit. Trashed notes count until the trash is emptied, because their files are still on disk. An upload past the limit is refused with 507 Insufficient Storage, and a phone or desktop retries that file on each sync, so it goes through once there is room. The Account diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index 346b0fa..f402716 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -189,6 +189,8 @@ export interface ServerSetting { maximum: number | null; /** Strings: the allowed values, drawn as a choice. */ choices: string[] | null; + /** What each choice is called, in the same order; null to show the values. */ + choice_labels: string[] | null; /** A credential (the SMTP password). Its value never comes back; `is_set` says * whether one is saved, and leaving the field empty keeps it. */ secret: boolean; diff --git a/frontend/src/components/AccountList.vue b/frontend/src/components/AccountList.vue index f1f6abb..3c29324 100644 --- a/frontend/src/components/AccountList.vue +++ b/frontend/src/components/AccountList.vue @@ -43,7 +43,7 @@ onMounted(() => { diff --git a/src/inkwell/settings.py b/src/inkwell/settings.py index 37c26a1..6982b03 100644 --- a/src/inkwell/settings.py +++ b/src/inkwell/settings.py @@ -38,6 +38,9 @@ class SettingDef: maximum: int | None = None # Strings only: the values allowed, shown as a choice rather than a text field. choices: tuple[str, ...] | None = None + # What each choice is called in the UI, in the same order. Without it the stored + # value is shown, which reads fine for "Inkwell" and badly for "starttls". + choice_labels: tuple[str, ...] | None = None # A credential. The admin API never sends its value back, only whether one is # set, and saving it empty leaves it as it was (#5266). secret: bool = False @@ -144,6 +147,7 @@ REGISTRY: list[SettingDef] = [ "How the connection to the SMTP server is secured.", "Email", choices=("starttls", "tls", "none"), + choice_labels=("STARTTLS (usually port 587)", "TLS (usually port 465)", "None (unencrypted)"), ), SettingDef("smtp_username", "string", "", "SMTP username", "Leave empty if no sign-in is needed.", "Email"), SettingDef( @@ -351,6 +355,7 @@ async def get_admin_settings(db) -> list[dict]: "is_set": bool(value) if d.secret else None, "secret": d.secret, "choices": list(d.choices) if d.choices else None, + "choice_labels": list(d.choice_labels) if d.choice_labels else None, "default": d.default, "label": d.label, "description": d.description, diff --git a/src/inkwell/storage.py b/src/inkwell/storage.py index 41c1437..c9ff0fb 100644 --- a/src/inkwell/storage.py +++ b/src/inkwell/storage.py @@ -1,7 +1,7 @@ """How much attachment storage an account may use (#2939 §4). `max_attachment_mb` caps one file; this caps an account's total, so one account -can't fill the volume. The limit is Settings → Attachments → `storage_quota_gb`; +can't fill the volume. The limit is Settings → General → `storage_quota_gb`; 0 turns it off. Admins have no limit, because the instance is theirs to fill. What counts is every attachment on every note the account owns, trashed notes diff --git a/tests/test_settings.py b/tests/test_settings.py index 9f2ac60..ca101b1 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -68,3 +68,11 @@ def test_the_public_address_is_an_http_url_without_a_trailing_slash(): assert validate_updates({"public_url": ""}) == ({"public_url": ""}, None) clean, error = validate_updates({"public_url": "javascript:alert(1)"}) assert clean == {} and error + + +def test_choice_labels_name_every_choice(): + """The UI pairs labels with choices by position, so a label list of another length + would put one choice's name on another.""" + for d in REGISTRY: + if d.choice_labels is not None: + assert d.choices is not None and len(d.choice_labels) == len(d.choices), d.key