Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 19 additions & 12 deletions scripts/sync-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)), "..");
Expand Down Expand Up @@ -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,
Expand All @@ -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,
);
}

Expand Down
7 changes: 4 additions & 3 deletions src-tauri/src/modules/sync/engine/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 17 additions & 13 deletions src/modules/sync/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
36 changes: 25 additions & 11 deletions src/modules/vault/JoinSyncScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex h-full flex-1 items-center justify-center p-6">
<form
onSubmit={submit}
className="flex max-h-full w-full max-w-sm flex-col gap-4 overflow-y-auto"
>
<div className="flex h-full flex-1 overflow-y-auto p-6">
<form onSubmit={submit} className="m-auto flex w-full max-w-3xl flex-col gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold">Join a synced vault</h1>
<p className="text-muted-foreground text-xs">
Expand Down Expand Up @@ -85,10 +86,23 @@ export function JoinSyncScreen({ onCreateInstead }: { onCreateInstead: () => voi
</div>
) : null}

<Button type="submit" disabled={!canSubmit}>
{busy ? <Spinner /> : null}
Join vault
</Button>
<div className="flex flex-col gap-2">
<Button
type="submit"
disabled={!canSubmit}
aria-describedby={blockers.length > 0 ? "join-blockers" : undefined}
>
{busy ? <Spinner /> : null}
Join vault
</Button>
{blockers.length > 0 ? (
<ul id="join-blockers" className="text-muted-foreground flex flex-col gap-0.5 text-xs">
{blockers.map((blocker) => (
<li key={blocker}>{blocker}</li>
))}
</ul>
) : null}
</div>
</form>
</div>
);
Expand Down
18 changes: 13 additions & 5 deletions src/modules/vault/MasterPasswordFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
};

Expand All @@ -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,
Expand All @@ -44,7 +51,8 @@ export function useMasterPassword(): MasterPasswordDraft {
setPassword,
setConfirm,
setAcknowledged,
ready: longEnough && password === confirm && acknowledged,
problems,
ready: problems.length === 0,
};
}

Expand Down
5 changes: 4 additions & 1 deletion src/modules/vault/PasswordField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ export function PasswordField({
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
/>
<InputGroupAddon align="inline-end">
{/* 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. */}
<InputGroupAddon align="inline-end" className="pr-2 has-[>button]:mr-0">
<button
type="button"
onClick={() => setRevealed((r) => !r)}
Expand Down
51 changes: 34 additions & 17 deletions src/modules/vault/join/useJoinSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { syncJoin } from "@/modules/sync/ipc";
import { createSyncSettingsStore } from "@/modules/sync/store";
import {
configSubset,
connectionFieldsReady,
missingConnectionFields,
SYNC_CONFIG_EVENT,
SYNC_STORE_PATH,
type SyncConfig,
Expand All @@ -31,17 +31,26 @@ export const EMPTY_JOIN_CREDENTIALS: JoinCredentials = {
webdavPassword: "",
};

/** Whether the credential pair the selected provider reads is filled in. */
export function credentialsReady(credentials: JoinCredentials, provider: string): boolean {
return provider === "webdav"
? credentials.username.length > 0 && credentials.webdavPassword.length > 0
: credentials.accessKeyId.length > 0 && credentials.secretAccessKey.length > 0;
/** The labels of the credential fields the selected provider reads that are still blank. */
function missingCredentials(credentials: JoinCredentials, provider: string): string[] {
const fields: [label: string, value: string][] =
provider === "webdav"
? [
["Username", credentials.username],
["Password", credentials.webdavPassword],
]
: [
["Access key ID", credentials.accessKeyId],
["Secret access key", credentials.secretAccessKey],
];
return fields.filter(([, value]) => value.length === 0).map(([label]) => label);
}

/**
* Everything the join screen's form does: the one submit, its busy flag, its
* error, the `fresh` answer a remote with no keyfile gives, and the boolean the
* Submit button is disabled on.
* error, the `fresh` answer a remote with no keyfile gives, and `blockers`,
* every reason other than `busy` that Submit is disabled, which the screen
* lists under it.
*
* A LANDED JOIN WRITES THE CONFIGURATION, switched on. The scheduler reads it
* on the unlock that follows, and an off configuration makes it call
Expand All @@ -56,14 +65,14 @@ export function useJoinSync({
credentials,
passphrase,
password,
masterPasswordReady,
masterPasswordProblems,
}: {
config: SyncConfig;
credentials: JoinCredentials;
passphrase: string;
password: string;
/** Length, confirmation and the no-recovery acknowledgement, from `useMasterPassword`. */
masterPasswordReady: boolean;
/** `problems` from `useMasterPassword`: length, confirmation and the no-recovery acknowledgement. */
masterPasswordProblems: string[];
}) {
const refreshStatus = useVaultStore((s) => s.refreshStatus);
const refresh = useVaultStore((s) => s.refresh);
Expand All @@ -76,11 +85,19 @@ export function useJoinSync({
const [error, setError] = useState<string | null>(null);
const [fresh, setFresh] = useState(false);

const canSubmit =
masterPasswordReady &&
connectionFieldsReady(config, credentialsReady(credentials, config.provider)) &&
passphrase.length > 0 &&
!busy;
// Every reason Join vault is disabled, in the order the fields render. The
// list and the button cannot disagree: while it is non-empty Submit is
// disabled, and the only other reason is `busy`, which shows the spinner.
const empty = [
...missingConnectionFields(config),
...missingCredentials(credentials, config.provider),
...(passphrase.length === 0 ? ["Sync passphrase"] : []),
];
const blockers = [
...(empty.length > 0 ? [`Still empty: ${empty.join(", ")}.`] : []),
...masterPasswordProblems,
];
const canSubmit = blockers.length === 0 && !busy;

const submit = async (e: FormEvent) => {
e.preventDefault();
Expand Down Expand Up @@ -122,5 +139,5 @@ export function useJoinSync({
}
};

return { submit, busy, error, fresh, canSubmit };
return { submit, busy, error, fresh, canSubmit, blockers };
}
Loading