Sign in with Google, Apple…: remember prompt, item row, new-login picker
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -41,6 +41,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
notes: None,
|
notes: None,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
|
sign_in_with: None,
|
||||||
modified_at: 0,
|
modified_at: 0,
|
||||||
})?;
|
})?;
|
||||||
let broker = Broker::new(core, Arc::new(Quiet), PairingStore::in_memory());
|
let broker = Broker::new(core, Arc::new(Quiet), PairingStore::in_memory());
|
||||||
|
|||||||
@@ -81,6 +81,22 @@ struct Detail {
|
|||||||
totp: Option<String>,
|
totp: Option<String>,
|
||||||
notes: Option<String>,
|
notes: Option<String>,
|
||||||
tags: Vec<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)]
|
#[derive(Deserialize)]
|
||||||
@@ -92,6 +108,8 @@ struct NewLogin {
|
|||||||
totp: Option<String>,
|
totp: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
sign_in_with: Option<SignInView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -249,9 +267,19 @@ fn get_item(app: State<'_, App>, id: String) -> CmdResult<Detail> {
|
|||||||
totp: d.totp,
|
totp: d.totp,
|
||||||
notes: d.notes,
|
notes: d.notes,
|
||||||
tags: d.tags,
|
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.
|
/// Replaces an item's tags (the core normalises them); everything else about the item stays.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn set_tags(app: State<'_, App>, id: String, tags: Vec<String>) -> CmdResult<()> {
|
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,
|
notes: None,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
tags: item.tags,
|
tags: item.tags,
|
||||||
|
sign_in_with: item.sign_in_with.map(|w| kv_ffi::SignInWithDto { provider: w.provider, account: w.account }),
|
||||||
modified_at: 0,
|
modified_at: 0,
|
||||||
})
|
})
|
||||||
.map_err(err)
|
.map_err(err)
|
||||||
@@ -701,6 +730,7 @@ fn main() {
|
|||||||
pending_saves,
|
pending_saves,
|
||||||
pending_passkeys,
|
pending_passkeys,
|
||||||
set_tags,
|
set_tags,
|
||||||
|
sign_in_providers,
|
||||||
resolve_passkey,
|
resolve_passkey,
|
||||||
resolve_save,
|
resolve_save,
|
||||||
sync_status,
|
sync_status,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ fn login(title: &str) -> ItemDto {
|
|||||||
notes: None,
|
notes: None,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
|
sign_in_with: None,
|
||||||
modified_at: 0,
|
modified_at: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ fn pair_and_fill_through_the_native_host() {
|
|||||||
notes: None,
|
notes: None,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
|
sign_in_with: None,
|
||||||
modified_at: 0,
|
modified_at: 0,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
+21
-3
@@ -10,10 +10,11 @@ import {
|
|||||||
|
|
||||||
interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number }
|
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 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 PairingView { id: string; name: string; caller: string; words: string[] }
|
||||||
interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number }
|
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 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 PasskeyView { id: string; kind: "get" | "create"; origin: string; rp_id: string; rp_name: string; user_name: string; choices: PasskeyChoice[]; locked: boolean }
|
||||||
interface SyncStatus {
|
interface SyncStatus {
|
||||||
@@ -630,6 +631,10 @@ async function renderDetail(target: HTMLElement, id: string) {
|
|||||||
if (item.username) {
|
if (item.username) {
|
||||||
rows.push(fieldRow("Username", h("span", "", item.username), copyButton("Copy username", () => invoke("copy_secret", { id, field: "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) {
|
if (item.password) {
|
||||||
const value = h("span", "", dots("Password"));
|
const value = h("span", "", dots("Password"));
|
||||||
let shown = false;
|
let shown = false;
|
||||||
@@ -713,13 +718,17 @@ function renderEditor(target: HTMLElement) {
|
|||||||
const url = input({ type: "url", placeholder: "https://example.com", spellcheck: false });
|
const url = input({ type: "url", placeholder: "https://example.com", spellcheck: false });
|
||||||
const totp = input({ placeholder: "otpauth://totp/… or base32 secret" }, true);
|
const totp = input({ placeholder: "otpauth://totp/… or base32 secret" }, true);
|
||||||
const tags = input({ placeholder: "work, finance", spellcheck: false });
|
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 gen = button("Generate", "quiet", async () => { pass.value = await invoke<string>("generate_password", { length: 24 }); });
|
||||||
const err = errBox();
|
const err = errBox();
|
||||||
const save = button("Save", "primary");
|
const save = button("Save", "primary");
|
||||||
save.onclick = async () => {
|
save.onclick = async () => {
|
||||||
try {
|
try {
|
||||||
await invoke("save_item", {
|
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();
|
await render();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -731,6 +740,7 @@ function renderEditor(target: HTMLElement) {
|
|||||||
field("Title", title), field("Username", user),
|
field("Title", title), field("Username", user),
|
||||||
h("label", "kv-fieldset", h("span", "kv-label", "Password"), h("div", "row", pass, gen)),
|
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("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())))));
|
err, h("div", "actions", save, button("Cancel", "secondary", () => target.replaceChildren(emptyDetail())))));
|
||||||
title.focus();
|
title.focus();
|
||||||
}
|
}
|
||||||
@@ -1115,6 +1125,14 @@ function saveCard(s: SaveView): HTMLElement {
|
|||||||
};
|
};
|
||||||
yes.onclick = () => resolve(true);
|
yes.onclick = () => resolve(true);
|
||||||
no.onclick = () => resolve(false);
|
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",
|
return h("div", "kv-sheet prompt",
|
||||||
h("h2", "kv-title-2", s.update ? "Update saved password?" : "Save this login?"),
|
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\.)?/, "")),
|
h("div", "kv-card rows", h("div", "kv-frow", tile(s.origin.replace(/^https?:\/\/(www\.)?/, "")),
|
||||||
|
|||||||
Reference in New Issue
Block a user