Offer to save a password when a login is submitted
Submitting a form containing a password now offers to remember it. It goes into the macOS Keychain, through the Security framework rather than the `security` binary - a password passed as a command-line argument is visible in `ps` to anyone on the machine, however briefly. Never apps.json, never a log. The value travels as little as it can: the injected script hands it straight to Rust, which holds it in memory and tells the shell only which host and which username, since that is all the shell needs to ask the question. It is written on Save and dropped on anything else. Autofill is deliberately not built. Reading a password back out and injecting it into a page is a materially larger surface than offering to store one, and deserves its own decision.
This commit is contained in:
+54
-2
@@ -4,9 +4,16 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import * as api from "./api";
|
||||
import Nav from "./components/Nav";
|
||||
import Settings from "./components/Settings";
|
||||
import { BTN_PRIMARY } from "./components/ui";
|
||||
import { BTN_PRIMARY, Dialog } from "./components/ui";
|
||||
import { useAppearance, type Theme } from "./hooks/useAppearance";
|
||||
import type { Config, Group, HiddenEvent, NotificationClick, SwitchEvent } from "./types";
|
||||
import type {
|
||||
Config,
|
||||
Group,
|
||||
HiddenEvent,
|
||||
NotificationClick,
|
||||
PasswordOffer,
|
||||
SwitchEvent,
|
||||
} from "./types";
|
||||
|
||||
export default function App() {
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
@@ -16,6 +23,8 @@ export default function App() {
|
||||
const [theme, setTheme] = useAppearance("system");
|
||||
const [unread, setUnread] = useState<Record<string, number>>({});
|
||||
const [backdrop, setBackdrop] = useState<string | null>(null);
|
||||
const [offer, setOffer] = useState<PasswordOffer | null>(null);
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const booted = useRef(false);
|
||||
@@ -110,6 +119,8 @@ export default function App() {
|
||||
listen<[string, number][]>("unread-changed", (e) => {
|
||||
setUnread(Object.fromEntries(e.payload));
|
||||
}),
|
||||
// A login was submitted. The password is held in Rust; this only asks.
|
||||
listen<PasswordOffer>("password-offer", (e) => setOffer(e.payload)),
|
||||
];
|
||||
return () => {
|
||||
unlisten.forEach((p) => p.then((f) => f()));
|
||||
@@ -204,6 +215,47 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{offer && (
|
||||
<Dialog
|
||||
title="Save this password?"
|
||||
onCancel={() => {
|
||||
void api.discardPassword();
|
||||
setOffer(null);
|
||||
}}
|
||||
confirmLabel="Save"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
setSaved(await api.savePassword());
|
||||
} catch {
|
||||
setSaved(null);
|
||||
}
|
||||
setOffer(null);
|
||||
}}
|
||||
>
|
||||
{offer.account ? (
|
||||
<>
|
||||
<span className="font-medium">{offer.account}</span> at{" "}
|
||||
<span className="font-mono text-[12px]">{offer.host}</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
The login you just entered at{" "}
|
||||
<span className="font-mono text-[12px]">{offer.host}</span>.
|
||||
</>
|
||||
)}{" "}
|
||||
It goes into your macOS Keychain — not into this app's settings file, and
|
||||
nowhere else on disk.
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{saved && (
|
||||
<Dialog title="Saved" onCancel={() => setSaved(null)}>
|
||||
The password for <span className="font-mono text-[12px]">{saved}</span> is in
|
||||
your Keychain. You can see and remove it in Keychain Access, under{" "}
|
||||
<span className="font-mono text-[12px]">Work — {saved}</span>.
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{settingsOpen && (
|
||||
<Settings
|
||||
config={config}
|
||||
|
||||
@@ -58,3 +58,5 @@ export const appReports = () => invoke<[string, string][]>("app_reports");
|
||||
|
||||
export const unreadCounts = () => invoke<[string, number][]>("unread_counts");
|
||||
export const stageSnapshot = () => invoke<string | null>("stage_snapshot");
|
||||
export const savePassword = () => invoke<string>("save_password");
|
||||
export const discardPassword = () => invoke<void>("discard_password");
|
||||
|
||||
@@ -57,3 +57,10 @@ export interface NotificationClick {
|
||||
appId: string;
|
||||
notificationId: string;
|
||||
}
|
||||
|
||||
/** A login just submitted, offered for saving. Carries no password. */
|
||||
export interface PasswordOffer {
|
||||
appId: string;
|
||||
host: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user