From 762dc9ef7f3a005e295596254bbe5517f4a2cc7f Mon Sep 17 00:00:00 2001 From: Rendi Wijaya Date: Tue, 6 Oct 2026 11:04:48 +0700 Subject: [PATCH] fix(vault): let the join form follow the window and accept a blank Region The join screen was a fixed 384px column that scrolled on its own, so the wheel worked only over that column. The eye button's addon in PasswordField hung 3px past its input group, and the scrolling form turned that into a horizontal scrollbar. The screen is now the scroll container and the form is m-auto up to max-w-3xl, the Settings content width. The addon overrides pr-3 has-[>button]:-mr-1 with pr-2 has-[>button]:mr-0, so the button stays where it was and nothing hangs past the group. Join vault no longer requires an S3 Region. provider_config passes the region through as typed, Settings > Sync saves it blank, and a server that takes an empty one works with it (Cloudflare R2 reads it as auto). Bucket stays required for S3: a blank one builds {endpoint}//{key} paths. connectionFieldsReady becomes missingConnectionFields, which returns the labels of the blank fields, and useMasterPassword also returns problems, one sentence per rule the password still fails. useJoinSync builds blockers from both, the credential pair and the passphrase, and derives canSubmit from it. The screen lists the blockers under the button in muted text. scripts/sync-verify.ts checks the new connection gate, including the blank Region. Closes #22 --- CHANGELOG.md | 5 +++ scripts/sync-verify.ts | 31 ++++++++----- src-tauri/src/modules/sync/engine/types.rs | 7 +-- src/modules/sync/types.ts | 30 +++++++------ src/modules/vault/JoinSyncScreen.tsx | 36 ++++++++++----- src/modules/vault/MasterPasswordFields.tsx | 18 +++++--- src/modules/vault/PasswordField.tsx | 5 ++- src/modules/vault/join/useJoinSync.ts | 51 ++++++++++++++-------- 8 files changed, 121 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 655b660..28b9f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ The latest release only. Every earlier version: - The inline picker's "more logins" footer is now a row that opens the Subclave popup, which lists the logins saved for other hosts of the domain. Where the browser cannot open the popup from the page, the footer names the Extensions menu and the fill shortcut instead. +### Fixed + +- The "Join a synced vault" screen scrolls as a whole and its form widens with the window, up to the Settings content width. No horizontal scrollbar appears. +- Join vault no longer stays disabled on a blank S3 Region, which Settings > Sync already saves. While the button is disabled, a list under it names every field that is still empty or invalid. + ## [0.1.1] - 06-10-2026 No change to the desktop app or to the extension's code. diff --git a/scripts/sync-verify.ts b/scripts/sync-verify.ts index 25a417f..97b79f5 100644 --- a/scripts/sync-verify.ts +++ b/scripts/sync-verify.ts @@ -31,9 +31,9 @@ import type { SyncStatus, } from "@/modules/sync/types"; import { - connectionFieldsReady, DEFAULT_SYNC_CONFIG, EMPTY_SYNC_STATUS, + missingConnectionFields, } from "@/modules/sync/types"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); @@ -486,9 +486,9 @@ check( !syncStore.includes("readDirty"), ); -// The join form's gate. Its whole value is that it is a checked predicate -// rather than a condition inside a component: a flipped one makes a provider -// permanently unsubmittable while the form still looks complete. +// The join form's connection gate. Its whole value is that it is a checked +// function rather than a condition inside a component: a field wrongly listed +// makes the join form refuse a connection Settings > Sync saves. { const base: SyncConfig = { enabled: true, @@ -500,19 +500,26 @@ check( cas: true, }; const webdav: SyncConfig = { ...base, provider: "webdav", region: "", bucket: "" }; - check("a webdav connection needs no region and no bucket", connectionFieldsReady(webdav, true)); + const missing = (config: SyncConfig): string => missingConnectionFields(config).join(", "); check( - "a webdav connection still needs its own credential pair", - !connectionFieldsReady(webdav, false), + "a webdav connection needs no region and no bucket", + missing(webdav) === "", + missing(webdav), ); - check("an s3 connection needs a region and a bucket", connectionFieldsReady(base, true)); + check("a complete s3 connection is ready", missing(base) === "", missing(base)); + const blankRegion = missing({ ...base, region: "" }); check( - "an s3 connection without a bucket is refused", - !connectionFieldsReady({ ...base, bucket: "" }, true), + "an s3 connection with a blank region is ready, as Settings > Sync saves one", + blankRegion === "", + blankRegion, ); + const blankBucket = missing({ ...base, bucket: " " }); + check("an s3 connection without a bucket names Bucket", blankBucket === "Bucket", blankBucket); + const blankEndpoint = missing({ ...webdav, endpoint: " " }); check( - "a connection with no endpoint is refused", - !connectionFieldsReady({ ...webdav, endpoint: " " }, true), + "a connection with no endpoint names Endpoint", + blankEndpoint === "Endpoint", + blankEndpoint, ); } diff --git a/src-tauri/src/modules/sync/engine/types.rs b/src-tauri/src/modules/sync/engine/types.rs index a28649e..fc5076f 100644 --- a/src-tauri/src/modules/sync/engine/types.rs +++ b/src-tauri/src/modules/sync/engine/types.rs @@ -135,9 +135,10 @@ pub struct SyncConfigArg { pub provider: String, pub endpoint: String, /// S3's alone. `#[serde(default)]` so a WebDAV caller does not have to send - /// an S3 field `provider_config` ignores. The S3 form still requires it - /// (`connectionFieldsReady` in `src/modules/sync/types.ts`) and - /// `provider_config` still puts it into the JSON `S3Config` deserializes. + /// an S3 field `provider_config` ignores. Neither form requires it: a server + /// that takes an empty region (Cloudflare R2 reads one as `auto`) works with + /// it blank, and `provider_config` puts it as typed into the JSON `S3Config` + /// deserializes. #[serde(default)] pub region: String, /// S3's alone, by the same default as the `region` field. diff --git a/src/modules/sync/types.ts b/src/modules/sync/types.ts index 34affb8..df93104 100644 --- a/src/modules/sync/types.ts +++ b/src/modules/sync/types.ts @@ -131,22 +131,26 @@ export function configSubset(config: SyncConfig): SyncConfigArg { } /** - * Whether the fields a provider actually needs are filled in. + * The connection fields a provider needs that are still blank, by the label the + * join form shows; empty when the connection is complete. * - * SHARED WITH `scripts/sync-verify.ts`, and that is the point: this is the one - * boolean in the join form that can make a whole provider unusable while the - * form still looks complete, so it lives where a check can drive it rather than - * inside a component. + * SHARED WITH `scripts/sync-verify.ts`, and that is the point: a field wrongly + * listed here makes a whole provider unusable from the join form while Settings + * > Sync, which has no such check, saves the same values. * - * REGION AND BUCKET ARE S3'S ALONE. A WebDAV configuration refuses unknown - * fields, so demanding them for a WebDAV server would make the form impossible - * to submit. + * REGION IS NEVER LISTED. `provider_config` in + * `src-tauri/src/modules/sync/engine/commands.rs` passes it through as typed, a + * server that takes an empty one (Cloudflare R2 reads it as `auto`) works with + * it blank, and Settings > Sync saves it blank. + * + * BUCKET IS S3'S ALONE. A WebDAV configuration refuses unknown fields, so + * demanding one for a WebDAV server would make the form impossible to submit. */ -export function connectionFieldsReady(config: SyncConfig, credentialsReady: boolean): boolean { - if (config.endpoint.trim().length === 0) return false; - if (!credentialsReady) return false; - if (config.provider === "webdav") return true; - return config.region.trim().length > 0 && config.bucket.trim().length > 0; +export function missingConnectionFields(config: SyncConfig): string[] { + const missing: string[] = []; + if (config.endpoint.trim().length === 0) missing.push("Endpoint"); + if (config.provider !== "webdav" && config.bucket.trim().length === 0) missing.push("Bucket"); + return missing; } /** A device with sync never configured. Off, and naming nothing. */ diff --git a/src/modules/vault/JoinSyncScreen.tsx b/src/modules/vault/JoinSyncScreen.tsx index 6ec178e..2bd1dd8 100644 --- a/src/modules/vault/JoinSyncScreen.tsx +++ b/src/modules/vault/JoinSyncScreen.tsx @@ -24,20 +24,21 @@ export function JoinSyncScreen({ onCreateInstead }: { onCreateInstead: () => voi const [passphrase, setPassphrase] = useState(""); const masterPassword = useMasterPassword(); - const { submit, busy, error, fresh, canSubmit } = useJoinSync({ + const { submit, busy, error, fresh, canSubmit, blockers } = useJoinSync({ config, credentials, passphrase, password: masterPassword.password, - masterPasswordReady: masterPassword.ready, + masterPasswordProblems: masterPassword.problems, }); + // The whole screen scrolls, not the form, so the wheel works anywhere on it. + // `m-auto` centres a short form and collapses to 0 on a tall one, which keeps + // its top reachable. `max-w-3xl` is the Settings window's content width, so + // the rows shared with Settings > Sync wrap the same in both. return ( -
-
+
+

Join a synced vault

@@ -85,10 +86,23 @@ export function JoinSyncScreen({ onCreateInstead }: { onCreateInstead: () => voi

) : null} - +
+ + {blockers.length > 0 ? ( +
    + {blockers.map((blocker) => ( +
  • {blocker}
  • + ))} +
+ ) : null} +
); diff --git a/src/modules/vault/MasterPasswordFields.tsx b/src/modules/vault/MasterPasswordFields.tsx index bba2bed..1461937 100644 --- a/src/modules/vault/MasterPasswordFields.tsx +++ b/src/modules/vault/MasterPasswordFields.tsx @@ -10,8 +10,8 @@ export const MIN_PASSWORD_LENGTH = 8; /** * The master-password half of a first-run screen: the password, its - * confirmation, and the no-recovery acknowledgement, plus the one boolean both - * screens gate Submit on. + * confirmation, and the no-recovery acknowledgement, plus `problems`, every + * rule it still fails, and `ready`, which is `problems` being empty. */ export type MasterPasswordDraft = { password: string; @@ -20,7 +20,9 @@ export type MasterPasswordDraft = { setPassword: (value: string) => void; setConfirm: (value: string) => void; setAcknowledged: (value: boolean) => void; - /** Long enough, confirmed, and acknowledged. A screen still has to add `!busy`. */ + /** Every rule the password still fails, one sentence each, in the order the fields render; empty once `ready`. */ + problems: string[]; + /** Long enough, confirmed, and acknowledged: `problems` is empty. A screen still has to add `!busy`. */ ready: boolean; }; @@ -33,9 +35,14 @@ export function useMasterPassword(): MasterPasswordDraft { const [confirm, setConfirm] = useState(""); const [acknowledged, setAcknowledged] = useState(false); + const problems: string[] = []; // Counted by code point rather than UTF-16 unit, so four emoji are four // characters and not eight. - const longEnough = [...password].length >= MIN_PASSWORD_LENGTH; + if ([...password].length < MIN_PASSWORD_LENGTH) { + problems.push(`Master password needs at least ${MIN_PASSWORD_LENGTH} characters.`); + } + if (confirm !== password) problems.push("The confirmation doesn't match the master password."); + if (!acknowledged) problems.push("The no-recovery box isn't ticked."); return { password, @@ -44,7 +51,8 @@ export function useMasterPassword(): MasterPasswordDraft { setPassword, setConfirm, setAcknowledged, - ready: longEnough && password === confirm && acknowledged, + problems, + ready: problems.length === 0, }; } diff --git a/src/modules/vault/PasswordField.tsx b/src/modules/vault/PasswordField.tsx index 782b22b..ec9a2f4 100644 --- a/src/modules/vault/PasswordField.tsx +++ b/src/modules/vault/PasswordField.tsx @@ -50,7 +50,10 @@ export function PasswordField({ onKeyDown={onKeyDown} onKeyUp={onKeyUp} /> - + {/* Overrides the addon's `pr-3 has-[>button]:-mr-1`: the button sits in the + same place, but the addon no longer hangs 3px past the group, which a + scrolling parent turns into a horizontal scrollbar. */} +