Sign in with Google, Apple…: remember prompt, item row, new-login picker
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-24 15:29:50 +07:00
co-authored by Claude Opus 5.5
parent e2a3c47825
commit a8f56ee00f
5 changed files with 54 additions and 3 deletions
+1
View File
@@ -41,6 +41,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
notes: None,
favorite: false,
tags: vec![],
sign_in_with: None,
modified_at: 0,
})?;
let broker = Broker::new(core, Arc::new(Quiet), PairingStore::in_memory());
+30
View File
@@ -81,6 +81,22 @@ struct Detail {
totp: Option<String>,
notes: Option<String>,
tags: Vec<String>,
/// "Google · [email protected]" when the account signs in with a provider.
sign_in_with: Option<SignInView>,
}
#[derive(Serialize, Deserialize)]
struct SignInView {
provider: String,
#[serde(default)]
provider_name: String,
account: Option<String>,
}
#[derive(Serialize)]
struct ProviderView {
id: String,
name: String,
}
#[derive(Deserialize)]
@@ -92,6 +108,8 @@ struct NewLogin {
totp: Option<String>,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
sign_in_with: Option<SignInView>,
}
#[derive(Serialize)]
@@ -249,9 +267,19 @@ fn get_item(app: State<'_, App>, id: String) -> CmdResult<Detail> {
totp: d.totp,
notes: d.notes,
tags: d.tags,
sign_in_with: d.sign_in_with.map(|w| SignInView {
provider_name: kv_ffi::sign_in_provider_name(w.provider.clone()),
provider: w.provider,
account: w.account,
}),
})
}
#[tauri::command]
fn sign_in_providers() -> Vec<ProviderView> {
kv_ffi::sign_in_providers().into_iter().map(|p| ProviderView { id: p.id, name: p.name }).collect()
}
/// Replaces an item's tags (the core normalises them); everything else about the item stays.
#[tauri::command]
fn set_tags(app: State<'_, App>, id: String, tags: Vec<String>) -> CmdResult<()> {
@@ -276,6 +304,7 @@ fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
notes: None,
favorite: false,
tags: item.tags,
sign_in_with: item.sign_in_with.map(|w| kv_ffi::SignInWithDto { provider: w.provider, account: w.account }),
modified_at: 0,
})
.map_err(err)
@@ -701,6 +730,7 @@ fn main() {
pending_saves,
pending_passkeys,
set_tags,
sign_in_providers,
resolve_passkey,
resolve_save,
sync_status,
+1
View File
@@ -38,6 +38,7 @@ fn login(title: &str) -> ItemDto {
notes: None,
favorite: false,
tags: vec![],
sign_in_with: None,
modified_at: 0,
}
}
+1
View File
@@ -90,6 +90,7 @@ fn pair_and_fill_through_the_native_host() {
notes: None,
favorite: false,
tags: vec![],
sign_in_with: None,
modified_at: 0,
})
.unwrap();
+21 -3
View File
@@ -10,10 +10,11 @@ import {
interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number }
interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean; has_totp: boolean; tags: string[] }
interface ItemDto { id: string; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null; tags: string[] }
interface SignInView { provider: string; provider_name: string; account: string | null }
interface ItemDto { id: string; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null; tags: string[]; sign_in_with: SignInView | null }
interface PairingView { id: string; name: string; caller: string; words: string[] }
interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number }
interface SaveView { id: string; origin: string; username: string; update: boolean }
interface SaveView { id: string; origin: string; username: string; update: boolean; provider: string | null }
interface PasskeyChoice { credential_id: string; user_name: string; title: string }
interface PasskeyView { id: string; kind: "get" | "create"; origin: string; rp_id: string; rp_name: string; user_name: string; choices: PasskeyChoice[]; locked: boolean }
interface SyncStatus {
@@ -630,6 +631,10 @@ async function renderDetail(target: HTMLElement, id: string) {
if (item.username) {
rows.push(fieldRow("Username", h("span", "", item.username), copyButton("Copy username", () => invoke("copy_secret", { id, field: "username" }))));
}
if (item.sign_in_with) {
const w = item.sign_in_with;
rows.push(fieldRow("Sign in with", h("span", "", w.account ? `${w.provider_name} · ${w.account}` : w.provider_name)));
}
if (item.password) {
const value = h("span", "", dots("Password"));
let shown = false;
@@ -713,13 +718,17 @@ function renderEditor(target: HTMLElement) {
const url = input({ type: "url", placeholder: "https://example.com", spellcheck: false });
const totp = input({ placeholder: "otpauth://totp/… or base32 secret" }, true);
const tags = input({ placeholder: "work, finance", spellcheck: false });
const provider = h("select", "kv-field", Object.assign(h("option", "", "Password only"), { value: "" }));
void invoke<{ id: string; name: string }[]>("sign_in_providers").then((ps) => provider.append(...ps.map((x) => Object.assign(h("option", "", x.name), { value: x.id }))));
const account = input({ placeholder: "Account used with the provider (optional)", spellcheck: false });
const gen = button("Generate", "quiet", async () => { pass.value = await invoke<string>("generate_password", { length: 24 }); });
const err = errBox();
const save = button("Save", "primary");
save.onclick = async () => {
try {
await invoke("save_item", {
item: { title: title.value, username: user.value || null, password: pass.value || null, urls: url.value ? [url.value] : [], totp: totp.value || null, tags: parseTags(tags.value) },
item: { title: title.value, username: user.value || null, password: pass.value || null, urls: url.value ? [url.value] : [], totp: totp.value || null, tags: parseTags(tags.value),
sign_in_with: provider.value ? { provider: provider.value, account: account.value.trim() || null } : null },
});
await render();
} catch (e) {
@@ -731,6 +740,7 @@ function renderEditor(target: HTMLElement) {
field("Title", title), field("Username", user),
h("label", "kv-fieldset", h("span", "kv-label", "Password"), h("div", "row", pass, gen)),
field("Website", url), field("One-time code", totp, "Optional."), field("Tags", tags, "Optional. Separate tags with commas."),
field("Sign in with", provider, "For sites where you use Google, Apple… instead of a password."), field("Provider account", account),
err, h("div", "actions", save, button("Cancel", "secondary", () => target.replaceChildren(emptyDetail())))));
title.focus();
}
@@ -1115,6 +1125,14 @@ function saveCard(s: SaveView): HTMLElement {
};
yes.onclick = () => resolve(true);
no.onclick = () => resolve(false);
if (s.provider) {
yes.textContent = "Remember";
return h("div", "kv-sheet prompt",
h("h2", "kv-title-2", `Signed in with ${s.provider}?`),
p("", `Remember that you sign in to ${s.origin.replace(/^https?:\/\/(www\.)?/, "")} with ${s.provider}${s.username ? ` (${s.username})` : ""}. Kestrove will offer it next time.`),
err,
h("div", "actions end", no, yes));
}
return h("div", "kv-sheet prompt",
h("h2", "kv-title-2", s.update ? "Update saved password?" : "Save this login?"),
h("div", "kv-card rows", h("div", "kv-frow", tile(s.origin.replace(/^https?:\/\/(www\.)?/, "")),