diff --git a/DESIGN.md b/DESIGN.md index 6b4174b7..39ddc223 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -161,12 +161,14 @@ The window is frameless (`decorations: false`; macOS keeps its traffic lights th pinned the pinned accounts gather there, out of their groups, in a section that does not fold. While reordering it stays first; every other section moves, 未分组 among them. - 密码 (boards 01–05 of Penpot 「Lockra 密码管理」): the header as on 验证码 (the facts read N 个登录项 - · N 个分组 · the sync), the search pill (`min(26rem, 36vw)`, the `/` keycap; absent while there are - no logins) and the 「添加登录项」 pill, a menu: 新建登录项… · Ctrl N, 生成密码… · Ctrl G, then - 从其他应用导入…. The tools: the filters (全部, 收藏, each group, 未分组; shown when there is more - than one), the 标签 menu button (`TagFilter`: tags with counts, several at once, a login must - carry all), then on the right the sort `Select` (按名称, 最近使用, 最近修改, 最近添加; favourites - first in each) and, in 全部 with groups, a ⋯ for 全部折叠 and 全部展开. Under them a grid + · N 个分组 · the sync), the search pill (`min(26rem, 30vw)`, narrower than 验证码's so that a 960 px + window keeps the title; the `/` keycap; absent while there are no logins), 生成器 (a pill on + `surface` with `dice`, its tooltip 生成器 · Ctrl G) for the 生成器 page, and the 「添加登录项」 pill, + a menu: 新建登录项… · Ctrl N, 生成密码… · Ctrl G (the 生成器 page too), then 从其他应用导入…. The + tools: the filters (全部, 收藏, each group, 未分组; shown when there is more than one), the 标签 + menu button (`TagFilter`: tags with counts, several at once, a login must carry all), then on the + right the sort `Select` (按名称, 最近使用, 最近修改, 最近添加; favourites first in each) and, in 全部 + with groups, a ⋯ for 全部折叠 and 全部展开. Under them a grid `minmax(0, 5fr) minmax(0, 3fr)`, 14 px apart: the list in one card (`PasswordRow`, 64 px; sections with folding headings in 全部 only) and the detail pane (`ItemDetail`), sticky 14 px under the title bar, whose card scrolls on its own: the one exception to "only the page body scrolls". A @@ -183,6 +185,16 @@ The window is frameless (`decorations: false`; macOS keeps its traffic lights th (`FieldRow`s, secrets as dots, the linked code with its ring from the code frames), 网站 (`UrlRow`: the address and its matching as a `Badge`), 备注 and 自定义字段 (dots until 显示, hidden again after 120 s), 整理, 填充与保护 and 记录 (`KeyValueRow`s). +- 生成器, a page of 密码 with no sidebar row of its own (the sidebar keeps 密码 current): the 生成器 + pill in 密码's header, Ctrl G, 生成密码… in the add menu and the palette lead there. The header + (密码生成器, a line on what it makes, and a ghost 返回密码 with `chevronLeft`), then two `Panel`s. + 生成密码 holds the generator's controls as in its dialog (§3), for no site, with a primary 复制 + beside 重新生成 in place of the copy icon and no 使用此密码. 生成历史 has its count (mono, on the + right), a 12 px `fg-muted` line on what goes in and how long it stays (the core's 100 passwords + for 90 days, this device only) and 查看历史… (off while it is empty), which asks for the proof; the + rows then show under the line as in the dialog, the header carrying the seconds left, 隐藏 + (`text-muted`) and 清空历史… (`text-danger`), which asks first (the question, 取消 and a `danger` + 清空). They hide after 120 s and as soon as the page is left. A login's form keeps the dialog. - The phone (board A2): a 30 px title with 已解锁 · N 个账号 and round 添加账号 and 锁定 buttons; the search pill; 「最近使用」; the filters, scrolling sideways, then reorder; each row a card of its own (its ⋯ always shown: there is no hover); a floating dock over the list, which lifts the @@ -281,13 +293,14 @@ Boards 06–12 of Penpot 「Lockra 密码管理」. them, each field's reason under it, the focus on the first. - **显示密码** (520 px): a `Banner warn`, the password mono 26 / 600 on `inset`, its length and kinds in 11.5 px, the `ScoreMeter`, the Linux note, and a 14 px ring with the seconds before it hides. -- **生成密码** (520 px): `Segmented` 随机密码 / 口令 (220 px), the result mono 22 / 600 (18 for a - passphrase) on `inset` with 重新生成 and 复制, the `ScoreMeter` with its bits, the rule's `info` - line or a `Banner danger bar` with 忽略规则, then labelled rows: 长度 (`Slider`, 4–128), - the four character groups as toggle chips (on: `accent-soft`, an `accent` ring and a check), - 避免易混字符, 排除字符; for a passphrase 单词数, 分隔符, 首字母大写, 加一个数字. 生成历史 · n 条 at - the bottom left opens the history after the proof: mono 13 / 500 rows with the time and the site, - 清空历史… in `text-danger`, hidden after 120 s. +- **生成密码** (520 px, from the form's 生成…; the 生成器 page draws the same panels, §2): + `Segmented` 随机密码 / 口令 (220 px), the result mono 22 / 600 (18 for a passphrase) on `inset` + with 重新生成 and 复制, the `ScoreMeter` with its bits, the rule's `info` line or a + `Banner danger bar` with 忽略规则, then labelled rows: 长度 (`Slider`, 4–128), the four character + groups as toggle chips (on: `accent-soft`, an `accent` ring and a check), 避免易混字符, 排除字符; + for a passphrase 单词数, 分隔符, 首字母大写, 加一个数字. 使用此密码 is the primary button. 生成历史 · + n 条 at the bottom left opens the history after the proof: mono 13 / 500 rows with the time and + the site, 清空历史… in `text-danger` (which asks first, in a dialog over it), hidden after 120 s. - **验证身份** (440 px): the prompt, the system check's button above the master password, the action's own button. - **The command palette**: a login's row carries its actions (`CommandItem.actions`): Enter runs diff --git a/apps/desktop/src/app/commands.ts b/apps/desktop/src/app/commands.ts index 7adb560c..9da24e9e 100644 --- a/apps/desktop/src/app/commands.ts +++ b/apps/desktop/src/app/commands.ts @@ -16,11 +16,12 @@ import { import { useMemo } from "react"; import { useItemActions } from "../features/items/actions"; import { useDispatch, useGuarded } from "./dispatch"; -import { PAGES, type PageId, useShell } from "./shell-state"; +import { PAGES, type PageId, passwordsPage, useShell } from "./shell-state"; const PAGE_ICONS = { codes: "key", passwords: "lock", + generator: "dice", import: "download", export: "upload", backup: "archive", @@ -121,7 +122,7 @@ export function useCommands(onCopy?: (copied: Promise) => void): Comman } } for (const page of PAGES) { - if (page === "passwords" && !passwords) continue; + if (passwordsPage(page) && !passwords) continue; items.push({ id: `page:${page}`, group: pages, diff --git a/apps/desktop/src/app/shell-state.tsx b/apps/desktop/src/app/shell-state.tsx index 9096f1b1..3820b937 100644 --- a/apps/desktop/src/app/shell-state.tsx +++ b/apps/desktop/src/app/shell-state.tsx @@ -3,13 +3,18 @@ import type { ExportStarted } from "@lockra/shared"; import { type ReactNode, createContext, useCallback, useContext, useMemo, useState } from "react"; -export const PAGES = ["codes", "passwords", "import", "export", "backup"] as const; +export const PAGES = ["codes", "passwords", "generator", "import", "export", "backup"] as const; export type PageId = (typeof PAGES)[number]; export function isPageId(value: string): value is PageId { return PAGES.some((page) => page === value); } +/** The pages of password management: offered only where the build has it. */ +export function passwordsPage(page: PageId): boolean { + return page === "passwords" || page === "generator"; +} + export type SettingsSection = "general" | "appearance" | "security" | "sync" | "browser" | "about"; export type Overlay = @@ -25,8 +30,7 @@ export type Overlay = | { type: "item_edit"; id: string } | { type: "item_delete"; id: string } | { type: "item_move"; id: string } - | { type: "item_reveal"; id: string } - | { type: "generator" }; + | { type: "item_reveal"; id: string }; export interface ShellState { page: PageId; diff --git a/apps/desktop/src/features/items/GeneratorDialog.tsx b/apps/desktop/src/features/items/GeneratorDialog.tsx index 6e1d0d7b..8609c059 100644 --- a/apps/desktop/src/features/items/GeneratorDialog.tsx +++ b/apps/desktop/src/features/items/GeneratorDialog.tsx @@ -1,218 +1,58 @@ -// The password generator (boards 09, 10): a random password or a passphrase from the EFF's list, -// its strength, the site's rules when it is made for one, and the options, kept as the next -// default. The core makes the password and keeps it: copying it (`generator_copy`) puts it in the -// history, and a login saved with it takes it without the webview sending it back. The history -// opens after the master password or the system check, and hides itself after two minutes. +// The generator over a login's form (boards 09, 10): 「使用此密码」 puts its password in the form (the +// login then saves it as the generator's), the icon beside 重新生成 copies it, and 生成历史 · n 条 at +// the bottom left opens the history in a view of its own after the proof. The generator page +// (pages/Generator.tsx) shows the same panels on its own. +import { Button, Dialog, useT, useUiState } from "@lockra/ui"; import { - type ErrorCode, - type GeneratedPastView, - type GeneratedView, - type GeneratorOptions, - REVEAL_SECONDS, - errorText, - isLockraError, - relativeTime, -} from "@lockra/shared"; -import { - Banner, - Button, - Dialog, - Icon, - IconButton, - Input, - ScoreMeter, - Segmented, - Slider, - Toggle, - cx, - useBackend, - useClock, - useT, - useUiState, -} from "@lockra/ui"; -import { useEffect, useState } from "react"; -import { useDispatch } from "../../app/dispatch"; -import { useSecretView } from "./secret-view"; -import { useVerify } from "./verify"; - -type PasswordOptions = Extract; -type PassphraseOptions = Extract; + ClearHistoryButton, + ClearHistoryDialog, + GeneratorHistoryList, + HistoryCountdown, + useGeneratorHistory, +} from "./GeneratorHistory"; +import { GeneratorPanel, useGenerator } from "./GeneratorPanel"; export interface GeneratorDialogProps { onClose: () => void; - /** Opened from a login's form: 「使用此密码」 hands the password over (the login then saves it as - * the generator's). Without it the dialog's button copies it. */ - onUse?: (password: string) => void; + /** 「使用此密码」: the password goes to the login's form. */ + onUse: (password: string) => void; /** The site it is made for: its rules apply when Lockra knows them. */ domain?: string; } export function GeneratorDialog({ onClose, onUse, domain }: GeneratorDialogProps) { const t = useT(); - const { backend } = useBackend(); - const { settings, generator } = useUiState(); - const dispatch = useDispatch(); - const verify = useVerify(); - const now = useClock(); - // What is asked of the generator: new options, or the same ones again (「重新生成」), each a new - // request. - const [request, setRequest] = useState<{ options: GeneratorOptions }>(() => ({ - options: settings.generator, - })); - const { options } = request; - const setOptions = (next: GeneratorOptions | ((current: GeneratorOptions) => GeneratorOptions)) => - setRequest((r) => ({ options: typeof next === "function" ? next(r.options) : next })); - const [made, setMade] = useState(undefined); - const [score, setScore] = useState(undefined); - const [failure, setFailure] = useState(undefined); - const [ignoreRules, setIgnoreRules] = useState(false); - const [copied, setCopied] = useState(false); - const [history, setHistory] = useState<{ list: GeneratedPastView[]; at: number } | undefined>( - undefined, - ); - const site = ignoreRules ? undefined : domain; - // However the dialog goes, the passwords it showed leave the screen and the secret view ends. - useSecretView(true); - // A password for the options (and the site's rules), made again whenever they change; an answer - // that comes after a newer request is dropped. - useEffect(() => { - let live = true; - backend - .dispatch({ - command: "generator_generate", - options: request.options, - ...(site === undefined ? {} : { domain: site }), - }) - .then((answer) => { - if (!live) return; - // A new password: nothing of it is copied yet. - setCopied(false); - setFailure(undefined); - setMade(answer); - backend.dispatch({ command: "password_score", password: answer.password }).then( - (strength) => { - if (live) setScore(strength.score); - }, - () => undefined, - ); - }) - .catch((error: unknown) => { - if (!live) return; - setMade(undefined); - setScore(undefined); - setFailure(isLockraError(error) ? error.code : "internal"); - }); - return () => { - live = false; - }; - }, [backend, request, site]); - const historyLeft = - history === undefined - ? REVEAL_SECONDS - : Math.max(0, REVEAL_SECONDS - Math.floor((now - history.at) / 1000)); - // The history hides itself two minutes after it opened. - useEffect(() => { - if (history === undefined) return undefined; - const timer = setTimeout( - () => setHistory(undefined), - Math.max(0, history.at + REVEAL_SECONDS * 1000 - Date.now()), - ); - return () => clearTimeout(timer); - }, [history]); - // The options become the next default once the password is used or copied. - const keepOptions = () => - void dispatch({ command: "settings_set", settings: { ...settings, generator: options } }); - const copy = async () => { - if ((await dispatch({ command: "generator_copy" })) === undefined) return; - setCopied(true); - keepOptions(); - }; + const { generator: kept } = useUiState(); + const generator = useGenerator(domain); + const history = useGeneratorHistory(); + const { made } = generator; const use = () => { - if (made === undefined || onUse === undefined) return; - keepOptions(); + if (made === undefined) return; + generator.keepOptions(); onUse(made.password); }; - const openHistory = async () => { - const list = await verify("view", t("generator.history.title"), (proof) => - backend.dispatch({ command: "generator_history_reveal", ...proof }), - ); - if (list !== undefined) setHistory({ list, at: Date.now() }); - }; - const clearHistory = async () => { - if ((await dispatch({ command: "generator_history_clear" })) !== undefined) - setHistory(undefined); - }; - const password = options.kind === "password" ? options : undefined; - const passphrase = options.kind === "passphrase" ? options : undefined; - const setPassword = (change: Partial) => - setOptions((o) => (o.kind === "password" ? { ...o, ...change } : o)); - const setPassphrase = (change: Partial) => - setOptions((o) => (o.kind === "passphrase" ? { ...o, ...change } : o)); - const mode = (kind: GeneratorOptions["kind"]) => - setOptions( - kind === "password" - ? { - kind: "password", - length: 20, - lower: true, - upper: true, - digits: true, - symbols: true, - avoid_ambiguous: false, - exclude: "", - } - : { kind: "passphrase", words: 6, separator: "-", capitalize: false, number: false }, - ); - if (history !== undefined) { + if (history.shown !== undefined) { return ( - setHistory(undefined)} - width={520} - hint={ - - {t("passwords.detail.hideIn", { s: historyLeft })} - - } - actions={ - <> - - - - }> - {history.list.length === 0 ? ( -

{t("generator.history.empty")}

- ) : ( -
    - {history.list.map((entry) => ( -
  • - - {entry.password} - - - {[ - relativeTime(t, entry.at_ms, now), - entry.site === null ? "" : t("generator.history.site", { site: entry.site }), - ] - .filter((s) => s !== "") - .join(" · ")} - -
  • - ))} -
- )} -
+ <> + } + actions={ + <> + + + + }> + + + {/* Over the history, as a dialog of its own. */} + + ); } return ( @@ -227,9 +67,9 @@ export function GeneratorDialog({ onClose, onUse, domain }: GeneratorDialogProps size="sm" icon="history" className="font-sans" - onClick={() => void openHistory()} + onClick={() => void history.open()} data-testid="generator-history-open"> - {t("generator.history.open", { n: generator.history_count })} + {t("generator.history.open", { n: kept.history_count })} } actions={ @@ -237,207 +77,18 @@ export function GeneratorDialog({ onClose, onUse, domain }: GeneratorDialogProps - {onUse === undefined ? ( - - ) : ( - - )} + }> -
- ({ - value, - label: t(`generator.mode.${value}`), - }))} - className="w-[220px]" - /> -
- - {made?.password ?? "—"} - - setRequest((r) => ({ ...r }))} - data-testid="generator-regenerate" - /> - {onUse !== undefined && ( - void copy()} - data-testid="generator-copy-icon" - /> - )} -
- {made !== undefined && } - {made !== undefined && score !== undefined && score < 2 && ( -

- - {t("generator.weak")} -

- )} - {made?.rules_met && domain !== undefined && ( -

- - {t("generator.rules", { site: domain })} -

- )} - {failure !== undefined && ( - setIgnoreRules(true)} - data-testid="generator-ignore-rules"> - {t("generator.ignoreRules")} - - ) : undefined - } - /> - )} - {password && ( -
- - setPassword({ length })} - data-testid="generator-length" - /> - - -
- {(["lower", "upper", "digits", "symbols"] as const).map((group) => ( - - ))} - {t("generator.eachGroup")} -
-
- - setPassword({ avoid_ambiguous })} - label={t("generator.avoidAmbiguous")} - /> - - - setPassword({ exclude: e.target.value })} - aria-label={t("generator.exclude")} - className="w-36" - data-testid="generator-exclude" - /> - -
- )} - {passphrase && ( -
- - setPassphrase({ words })} - data-testid="generator-words" - /> - - - setPassphrase({ separator })} - options={["-", ".", "_", " "].map((value) => ({ - value, - label: value === " " ? t("generator.space") : value, - }))} - /> - - -
- setPassphrase({ capitalize })} - label={t("generator.capitalize")} - /> - setPassphrase({ number })} - label={t("generator.number")} - /> -
-
-

{t("generator.wordlist")}

-
- )} +
+
); } - -function Row({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- {label} -
{children}
-
- ); -} diff --git a/apps/desktop/src/features/items/GeneratorHistory.tsx b/apps/desktop/src/features/items/GeneratorHistory.tsx new file mode 100644 index 00000000..12e9c546 --- /dev/null +++ b/apps/desktop/src/features/items/GeneratorHistory.tsx @@ -0,0 +1,161 @@ +// The generator's history (board 10), shared by the generator page and the dialog of a login's form: +// the passwords copied or saved with a login, and those the browser extension made on sign-up +// forms, kept by the core on this device only. They show after the master password or the system +// check (`generator_history_reveal`, a secret answer) and hide themselves after two minutes; while +// they show, they are a secret view. 「清空历史…」 asks first, as the app's other destructive actions +// do. +import { type GeneratedPastView, REVEAL_SECONDS, relativeTime } from "@lockra/shared"; +import { Button, type ButtonSize, Dialog, useBackend, useClock, useNow, useT } from "@lockra/ui"; +import { useEffect, useState } from "react"; +import { useDispatch } from "../../app/dispatch"; +import { useSecretView } from "./secret-view"; +import { useVerify } from "./verify"; + +/** The history as one view of it holds it. */ +export interface GeneratorHistory { + /** The passwords on the screen and when they came, `undefined` while hidden. */ + shown: { list: GeneratedPastView[]; at: number } | undefined; + /** Ask for the proof, then show them. */ + open: () => Promise; + hide: () => void; + /** 「清空历史…」 was chosen: its question is on the screen. */ + clearing: boolean; + askClear: () => void; + cancelClear: () => void; + /** The answer to the question: forget them all; what was shown goes too. */ + clear: () => Promise; +} + +export function useGeneratorHistory(): GeneratorHistory { + const t = useT(); + const { backend } = useBackend(); + const dispatch = useDispatch(); + const verify = useVerify(); + const [shown, setShown] = useState(undefined); + const [clearing, setClearing] = useState(false); + useSecretView(shown !== undefined); + // Hidden, the history takes its question with it. + const hide = () => { + setShown(undefined); + setClearing(false); + }; + // The history hides itself two minutes after it showed. + useEffect(() => { + if (shown === undefined) return undefined; + const timer = setTimeout( + () => { + setShown(undefined); + setClearing(false); + }, + Math.max(0, shown.at + REVEAL_SECONDS * 1000 - Date.now()), + ); + return () => clearTimeout(timer); + }, [shown]); + return { + shown, + open: async () => { + const list = await verify("view", t("generator.history.title"), (proof) => + backend.dispatch({ command: "generator_history_reveal", ...proof }), + ); + if (list !== undefined) setShown({ list, at: Date.now() }); + }, + hide, + clearing, + askClear: () => setClearing(true), + cancelClear: () => setClearing(false), + clear: async () => { + setClearing(false); + if ((await dispatch({ command: "generator_history_clear" })) !== undefined) hide(); + }, + }; +} + +/** The seconds before the history shown at `at` hides. */ +export function HistoryCountdown({ at, className }: { at: number; className?: string }) { + const t = useT(); + const now = useClock(); + // The shared clock ticks on whole seconds, so it can read just before the moment they came. + const left = Math.max(0, REVEAL_SECONDS - Math.max(0, Math.floor((now - at) / 1000))); + return ( + + {t("passwords.detail.hideIn", { s: left })} + + ); +} + +/** The passwords, newest first, each with when it was made and the site it was made for. */ +export function GeneratorHistoryList({ list }: { list: readonly GeneratedPastView[] }) { + const t = useT(); + const now = useNow(); + if (list.length === 0) + return

{t("generator.history.empty")}

; + return ( +
    + {list.map((entry) => ( +
  • + + {entry.password} + + + {[ + relativeTime(t, entry.at_ms, now), + entry.site === null ? "" : t("generator.history.site", { site: entry.site }), + ] + .filter((s) => s !== "") + .join(" · ")} + +
  • + ))} +
+ ); +} + +/** 「清空历史…」 for the history on the screen, which asks first; none to clear, it is off. */ +export function ClearHistoryButton({ + history, + size, +}: { + history: GeneratorHistory; + size?: ButtonSize; +}) { + const t = useT(); + return ( + + ); +} + +/** The question 「清空历史…」 asks: 取消 keeps the history, 清空 forgets it. Drawn beside the view that + * asked, never inside a frosted card or dialog, whose blur would hold a fixed dialog inside it. */ +export function ClearHistoryDialog({ history }: { history: GeneratorHistory }) { + const t = useT(); + return ( + + + + + } + /> + ); +} diff --git a/apps/desktop/src/features/items/GeneratorPanel.tsx b/apps/desktop/src/features/items/GeneratorPanel.tsx new file mode 100644 index 00000000..3b84c7fe --- /dev/null +++ b/apps/desktop/src/features/items/GeneratorPanel.tsx @@ -0,0 +1,355 @@ +// The password generator (boards 09, 10), shared by the generator page and the dialog a login's form +// opens: a random password or a passphrase from the EFF's list, its strength, the site's rules when +// it is made for one, and the options, kept as the next default. The core makes the password and +// keeps it: copying it (`generator_copy`) puts it in the history, and a login saved with it takes +// it without the webview sending it back. While a generator is on the screen, its password is a +// secret view. +import { + type ErrorCode, + type GeneratedView, + type GeneratorOptions, + errorText, + isLockraError, +} from "@lockra/shared"; +import { + Banner, + Button, + Icon, + IconButton, + Input, + ScoreMeter, + Segmented, + Slider, + Toggle, + cx, + useBackend, + useT, + useUiState, +} from "@lockra/ui"; +import { useEffect, useState } from "react"; +import { useDispatch } from "../../app/dispatch"; +import { useSecretView } from "./secret-view"; + +type PasswordOptions = Extract; +type PassphraseOptions = Extract; + +/** A generator on the screen: what it is asked for, what it made, and what can be done with it. */ +export interface Generator { + /** The site it makes passwords for: its rules apply when Lockra knows them. */ + domain: string | undefined; + options: GeneratorOptions; + setOptions: (change: (current: GeneratorOptions) => GeneratorOptions) => void; + /** The password made for the options, once the core has answered. */ + made: GeneratedView | undefined; + score: number | undefined; + failure: ErrorCode | undefined; + /** The same options again: another password (「重新生成」). */ + regenerate: () => void; + /** Passwords without the site's rules, which these options cannot meet. */ + ignoreRules: () => void; + /** The password shown is the one on the clipboard. */ + copied: boolean; + copy: () => Promise; + /** The options become the next default: done once the password is used or copied. */ + keepOptions: () => void; +} + +/** The generator's state for `domain` (none: passwords for no site), from the last options used. */ +export function useGenerator(domain?: string): Generator { + const { backend } = useBackend(); + const { settings } = useUiState(); + const dispatch = useDispatch(); + // What is asked of the generator: new options, or the same ones again (「重新生成」), each a new + // request. + const [request, setRequest] = useState<{ options: GeneratorOptions }>(() => ({ + options: settings.generator, + })); + const [made, setMade] = useState(undefined); + const [score, setScore] = useState(undefined); + const [failure, setFailure] = useState(undefined); + const [rulesIgnored, setRulesIgnored] = useState(false); + const [copied, setCopied] = useState(false); + const site = rulesIgnored ? undefined : domain; + // However the generator goes, the passwords it showed leave the screen and the secret view ends. + useSecretView(true); + // A password for the options (and the site's rules), made again whenever they change; an answer + // that comes after a newer request is dropped. + useEffect(() => { + let live = true; + backend + .dispatch({ + command: "generator_generate", + options: request.options, + ...(site === undefined ? {} : { domain: site }), + }) + .then((answer) => { + if (!live) return; + // A new password: nothing of it is copied yet. + setCopied(false); + setFailure(undefined); + setMade(answer); + backend.dispatch({ command: "password_score", password: answer.password }).then( + (strength) => { + if (live) setScore(strength.score); + }, + () => undefined, + ); + }) + .catch((error: unknown) => { + if (!live) return; + setMade(undefined); + setScore(undefined); + setFailure(isLockraError(error) ? error.code : "internal"); + }); + return () => { + live = false; + }; + }, [backend, request, site]); + const { options } = request; + const keepOptions = () => + void dispatch({ command: "settings_set", settings: { ...settings, generator: options } }); + return { + domain, + options, + setOptions: (change) => setRequest((r) => ({ options: change(r.options) })), + made, + score, + failure, + regenerate: () => setRequest((r) => ({ ...r })), + ignoreRules: () => setRulesIgnored(true), + copied, + copy: async () => { + if ((await dispatch({ command: "generator_copy" })) === undefined) return; + setCopied(true); + keepOptions(); + }, + keepOptions, + }; +} + +export interface GeneratorPanelProps { + generator: Generator; + /** How it copies the password, beside 重新生成: an icon where 「使用此密码」 is the main action (a + * login's form), the main button where nothing else takes it (the page). */ + copy: "icon" | "button"; +} + +/** The generator's controls: the kind, the password with its strength, then the options. */ +export function GeneratorPanel({ generator, copy }: GeneratorPanelProps) { + const t = useT(); + const { options, made, score, failure, domain, copied, setOptions } = generator; + const password = options.kind === "password" ? options : undefined; + const passphrase = options.kind === "passphrase" ? options : undefined; + const setPassword = (change: Partial) => + setOptions((o) => (o.kind === "password" ? { ...o, ...change } : o)); + const setPassphrase = (change: Partial) => + setOptions((o) => (o.kind === "passphrase" ? { ...o, ...change } : o)); + const mode = (kind: GeneratorOptions["kind"]) => + setOptions(() => + kind === "password" + ? { + kind: "password", + length: 20, + lower: true, + upper: true, + digits: true, + symbols: true, + avoid_ambiguous: false, + exclude: "", + } + : { kind: "passphrase", words: 6, separator: "-", capitalize: false, number: false }, + ); + const copyIt = () => void generator.copy(); + return ( +
+ ({ + value, + label: t(`generator.mode.${value}`), + }))} + className="w-[220px]" + /> +
+ + {made?.password ?? "—"} + + + {copy === "icon" ? ( + + ) : ( + + )} +
+ {made !== undefined && } + {made !== undefined && score !== undefined && score < 2 && ( +

+ + {t("generator.weak")} +

+ )} + {made?.rules_met && domain !== undefined && ( +

+ + {t("generator.rules", { site: domain })} +

+ )} + {failure !== undefined && ( + + {t("generator.ignoreRules")} + + ) : undefined + } + /> + )} + {password && ( +
+ + setPassword({ length })} + data-testid="generator-length" + /> + + +
+ {(["lower", "upper", "digits", "symbols"] as const).map((group) => ( + + ))} + {t("generator.eachGroup")} +
+
+ + setPassword({ avoid_ambiguous })} + label={t("generator.avoidAmbiguous")} + /> + + + setPassword({ exclude: e.target.value })} + aria-label={t("generator.exclude")} + className="w-36" + data-testid="generator-exclude" + /> + +
+ )} + {passphrase && ( +
+ + setPassphrase({ words })} + data-testid="generator-words" + /> + + + setPassphrase({ separator })} + options={["-", ".", "_", " "].map((value) => ({ + value, + label: value === " " ? t("generator.space") : value, + }))} + /> + + +
+ setPassphrase({ capitalize })} + label={t("generator.capitalize")} + /> + setPassphrase({ number })} + label={t("generator.number")} + /> +
+
+

{t("generator.wordlist")}

+
+ )} +
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} diff --git a/apps/desktop/src/features/items/ItemDialogs.tsx b/apps/desktop/src/features/items/ItemDialogs.tsx index b0339013..797c9c37 100644 --- a/apps/desktop/src/features/items/ItemDialogs.tsx +++ b/apps/desktop/src/features/items/ItemDialogs.tsx @@ -1,7 +1,7 @@ // The dialogs of a login, opened through the shell's overlay: new, edit (with its 「删除…」), delete, -// move to a group (board 04-g), show the password (board 08), and the generator on its own (Ctrl G, -// the add menu). A login that -// went away meanwhile (a sync deleted it, a restore replaced the vault) closes its dialog. +// move to a group (board 04-g) and show the password (board 08). A login that went away meanwhile (a +// sync deleted it, a restore replaced the vault) closes its dialog. The generator on its own is a +// page (pages/Generator.tsx). import { type ItemView, REVEAL_SECONDS, @@ -30,12 +30,11 @@ import { motionReduced } from "../../app/appearance"; import { useSubmit } from "../../app/dispatch"; import { useToaster } from "../../app/notices"; import { useShell } from "../../app/shell-state"; -import { GeneratorDialog } from "./GeneratorDialog"; import { ItemFormDialog } from "./ItemForm"; import { useItemActions } from "./actions"; import { useSecretView } from "./secret-view"; -/** The overlay's dialog, if it is one of a login's (or the generator). */ +/** The overlay's dialog, if it is one of a login's. */ export function ItemDialogs() { const shell = useShell(); const items = useItems(); @@ -76,8 +75,6 @@ export function ItemDialogs() { return item ? : null; case "item_reveal": return item ? : null; - case "generator": - return ; default: return null; } diff --git a/apps/desktop/src/pages/Generator.test.tsx b/apps/desktop/src/pages/Generator.test.tsx new file mode 100644 index 00000000..601f8772 --- /dev/null +++ b/apps/desktop/src/pages/Generator.test.tsx @@ -0,0 +1,392 @@ +import { + MOCK_PASSWORD, + MockBackend, + type MockOptions, + sampleEntries, + sampleItems, +} from "@lockra/shared/mock"; +import { act, fireEvent, screen, within } from "@testing-library/react"; +import type { UserEvent } from "@testing-library/user-event"; +import { ready, renderApp } from "../test/render"; + +/** The app with password management on and the sample logins. */ +function renderGenerator(options: MockOptions = {}) { + const entries = sampleEntries(); + const backend = new MockBackend({ + entries, + passwords: true, + items: sampleItems(entries), + ...options, + settings: { locale: "zh-cn", ...options.settings }, + }); + return renderApp({ backend }); +} + +function nav() { + return within(screen.getByRole("navigation", { name: "主导航" })); +} + +/** The generator page, by the 「生成器」 button in 密码's header. */ +async function openPage(user: UserEvent) { + await ready(); + await user.click(nav().getByRole("button", { name: /^密码/ })); + const passwords = within(await screen.findByTestId("page-passwords")); + await user.click(passwords.getByRole("button", { name: "生成器" })); + return within(await screen.findByTestId("page-generator")); +} + +function calls(backend: MockBackend, command: string) { + return backend.calls.filter((c) => c.command === command); +} + +function lastCall(backend: MockBackend, command: string) { + const found = calls(backend, command); + return found[found.length - 1]; +} + +function count(backend: MockBackend, command: string) { + return calls(backend, command).length; +} + +/** The password the page shows, once the generator has made it. */ +async function made(page: ReturnType): Promise { + const field = page.getByTestId("generator-password"); + await vi.waitFor(() => expect(field.textContent).not.toBe("—")); + return field.textContent ?? ""; +} + +/** 「查看历史…」, then the master password: the list once it shows. */ +async function viewHistory(user: UserEvent, history: ReturnType) { + await user.click(history.getByTestId("generator-history-view")); + const verify = within(await screen.findByRole("dialog", { name: "验证身份" })); + await user.type(verify.getByLabelText("主密码"), `${MOCK_PASSWORD}{Enter}`); + return history.findByTestId("generator-history"); +} + +describe("Generator page", () => { + it("belongs to 密码: its button in 密码's header, 密码 current in the sidebar, a way back", async () => { + const { user } = renderGenerator(); + await ready(); + // No sidebar row of its own: the sidebar keeps its rows (DESIGN.md §2). + const vault = within(nav().getByRole("group", { name: "保险库" })); + expect(vault.getAllByRole("button").map((b) => b.textContent)).toEqual([ + expect.stringMatching(/^验证码/), + expect.stringMatching(/^密码/), + ]); + await user.click(nav().getByRole("button", { name: /^密码/ })); + const passwords = within(await screen.findByTestId("page-passwords")); + expect(passwords.getByRole("button", { name: "生成器" })).toHaveAttribute( + "title", + "生成器 · Ctrl G", + ); + const page = await openPage(user); + expect(nav().getByRole("button", { name: /^密码/ })).toHaveAttribute("aria-current", "page"); + await user.click(page.getByRole("button", { name: "返回密码" })); + expect(await screen.findByTestId("page-passwords")).toBeInTheDocument(); + expect(screen.queryByTestId("page-generator")).toBeNull(); + }); + + it("is not offered without password management: no sidebar entry, Ctrl G or palette page", async () => { + const { user } = renderApp(); + await ready(); + expect(nav().queryByRole("button", { name: "生成器" })).toBeNull(); + await user.keyboard("{Control>}g{/Control}"); + expect(screen.getByTestId("page-codes")).toBeInTheDocument(); + expect(screen.queryByTestId("page-generator")).toBeNull(); + await user.keyboard("{Control>}k{/Control}"); + const palette = within(await screen.findByRole("dialog", { name: "命令菜单" })); + expect(palette.queryByText("生成器")).toBeNull(); + }); + + it("makes a password for no site and copies it through Rust, with no form to use it in", async () => { + const { user, backend } = renderGenerator(); + const page = await openPage(user); + expect(page.getByRole("heading", { level: 1, name: "密码生成器" })).toBeInTheDocument(); + expect(page.getByTestId("page-subtitle")).toHaveTextContent("生成随机密码或口令"); + await made(page); + expect(lastCall(backend, "generator_generate")).not.toHaveProperty("domain"); + expect(page.queryByTestId("generator-use")).toBeNull(); + expect(page.queryByTestId("generator-rules")).toBeNull(); + expect(page.getByTestId("generator-history-count")).toHaveTextContent("0 条"); + await user.click(page.getByTestId("generator-copy")); + expect(lastCall(backend, "generator_copy")).toBeDefined(); + expect(page.getByTestId("generator-copy")).toHaveTextContent("已复制"); + expect(lastCall(backend, "settings_set")).toMatchObject({ + settings: { generator: { kind: "password", length: 20 } }, + }); + // Copied, it is in the history. + expect(page.getByTestId("generator-history-count")).toHaveTextContent("1 条"); + // A new password is not copied yet. + await user.click(page.getByTestId("generator-regenerate")); + await vi.waitFor(() => + expect(page.getByTestId("generator-copy")).not.toHaveTextContent("已复制"), + ); + }); + + it("asks the generator for what its options say", async () => { + const { user, backend } = renderGenerator(); + const page = await openPage(user); + await made(page); + const asked = () => lastCall(backend, "generator_generate"); + fireEvent.change(page.getByTestId("generator-length-range"), { target: { value: "32" } }); + await user.click(page.getByTestId("generator-symbols")); + await user.click(page.getByRole("switch", { name: "避免易混字符" })); + await user.type(page.getByTestId("generator-exclude"), "0O"); + await vi.waitFor(() => + expect(asked()).toMatchObject({ + options: { + kind: "password", + length: 32, + symbols: false, + avoid_ambiguous: true, + exclude: "0O", + }, + }), + ); + // Short and of one kind: the page says it is easy to guess. + fireEvent.change(page.getByTestId("generator-length-range"), { target: { value: "4" } }); + await user.click(page.getByTestId("generator-upper")); + await user.click(page.getByTestId("generator-digits")); + expect( + await page.findByText("这个密码容易被猜到,建议加长或多用几类字符。"), + ).toBeInTheDocument(); + // No character at all: nothing to copy, and the reason. + await user.click(page.getByTestId("generator-lower")); + expect(await page.findByRole("alert")).toBeInTheDocument(); + expect(page.getByTestId("generator-password")).toHaveTextContent("—"); + expect(page.getByTestId("generator-copy")).toBeDisabled(); + + await user.click(page.getByRole("radio", { name: "口令" })); + fireEvent.change(page.getByTestId("generator-words-range"), { target: { value: "8" } }); + await user.click(page.getByRole("radio", { name: "." })); + await user.click(page.getByRole("switch", { name: "首字母大写" })); + await user.click(page.getByRole("switch", { name: "加一个数字" })); + await vi.waitFor(() => + expect(asked()).toMatchObject({ + options: { kind: "passphrase", words: 8, separator: ".", capitalize: true, number: true }, + }), + ); + expect(page.getByText("词表:EFF 大词表,7776 个英文单词")).toBeInTheDocument(); + await made(page); + expect(page.queryByRole("alert")).toBeNull(); + await user.click(page.getByTestId("generator-copy")); + expect(lastCall(backend, "settings_set")).toMatchObject({ + settings: { generator: { kind: "passphrase", words: 8 } }, + }); + expect(count(backend, "generator_copy")).toBe(1); + }); + + it("shows the history after the master password, then hides it after two minutes", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const { user, backend } = renderGenerator(); + await ready(); + // A passphrase made for a site and copied before (in a login's form, say). + await act(async () => { + await backend.dispatch({ + command: "generator_generate", + options: { + kind: "passphrase", + words: 4, + separator: "-", + capitalize: false, + number: false, + }, + domain: "example.com", + }); + await backend.dispatch({ command: "generator_copy" }); + }); + const page = await openPage(user); + const password = await made(page); + await user.click(page.getByTestId("generator-copy")); + const history = within(page.getByTestId("generator-history-panel")); + expect(history.getByTestId("generator-history-count")).toHaveTextContent("2 条"); + // The core's limits, and where the history stays. + expect(history.getByTestId("generator-history-kept")).toHaveTextContent( + "最多保留最近 100 个,最长 90 天,只保存在这台设备上", + ); + expect(history.queryByTestId("generator-history")).toBeNull(); + + const list = within(await viewHistory(user, history)); + expect(lastCall(backend, "generator_history_reveal")).toMatchObject({ + password: MOCK_PASSWORD, + }); + const rows = list.getAllByRole("listitem"); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveTextContent(password); + expect(rows[0]).toHaveTextContent("刚刚"); + expect(rows[0]).not.toHaveTextContent("用于"); + expect(rows[1]).toHaveTextContent("刚刚 · 用于 example.com"); + expect(history.getByTestId("generator-history-countdown")).toHaveTextContent( + "120 秒后自动隐藏", + ); + act(() => { + vi.advanceTimersByTime(121_000); + }); + expect(history.queryByTestId("generator-history")).toBeNull(); + expect(history.getByTestId("generator-history-view")).toBeEnabled(); + // The generated password is still on the screen: the secret view goes on. + expect(count(backend, "secret_view_closed")).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("hides the history on request and when the page is left, and asks again next time", async () => { + const { user, backend } = renderGenerator(); + let page = await openPage(user); + await made(page); + await user.click(page.getByTestId("generator-copy")); + let history = within(page.getByTestId("generator-history-panel")); + // A cancelled verification shows nothing. + await user.click(history.getByTestId("generator-history-view")); + await user.click( + within(await screen.findByRole("dialog", { name: "验证身份" })).getByRole("button", { + name: "取消", + }), + ); + expect(history.queryByTestId("generator-history")).toBeNull(); + expect(count(backend, "generator_history_reveal")).toBe(0); + + await viewHistory(user, history); + await user.click(history.getByTestId("generator-history-hide")); + expect(history.queryByTestId("generator-history")).toBeNull(); + expect(count(backend, "secret_view_closed")).toBe(0); + + await viewHistory(user, history); + await user.click(nav().getByRole("button", { name: /^密码/ })); + expect(await screen.findByTestId("page-passwords")).toBeInTheDocument(); + expect(count(backend, "secret_view_closed")).toBe(1); + + page = await openPage(user); + history = within(page.getByTestId("generator-history-panel")); + expect(history.queryByTestId("generator-history")).toBeNull(); + await viewHistory(user, history); + expect(count(backend, "generator_history_reveal")).toBe(3); + }); + + it("clears the history", async () => { + const { user, backend } = renderGenerator(); + const page = await openPage(user); + await made(page); + const history = within(page.getByTestId("generator-history-panel")); + // Nothing to show yet. + expect(history.getByTestId("generator-history-view")).toBeDisabled(); + await user.click(page.getByTestId("generator-copy")); + expect(history.getByTestId("generator-history-view")).toBeEnabled(); + await viewHistory(user, history); + // It asks first: cancelled, nothing goes. + await user.click(history.getByTestId("generator-history-clear")); + let confirm = within( + await screen.findByRole("dialog", { name: "清空生成历史?这些密码不会再显示。" }), + ); + await user.click(confirm.getByRole("button", { name: "取消" })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(count(backend, "generator_history_clear")).toBe(0); + expect(history.getByTestId("generator-history")).toBeInTheDocument(); + await user.click(history.getByTestId("generator-history-clear")); + confirm = within( + await screen.findByRole("dialog", { name: "清空生成历史?这些密码不会再显示。" }), + ); + await user.click(confirm.getByTestId("generator-history-clear-confirm")); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(lastCall(backend, "generator_history_clear")).toBeDefined(); + expect(history.queryByTestId("generator-history")).toBeNull(); + expect(history.getByTestId("generator-history-count")).toHaveTextContent("0 条"); + expect(history.getByTestId("generator-history-view")).toBeDisabled(); + }); + + it("is a page of the command palette", async () => { + const { user } = renderGenerator(); + await ready(); + await user.keyboard("{Control>}k{/Control}"); + await screen.findByRole("dialog", { name: "命令菜单" }); + await user.keyboard("生成器{Enter}"); + expect(await screen.findByTestId("page-generator")).toBeInTheDocument(); + }); +}); + +describe("The login form's generator", () => { + it("stays a dialog: its history after the proof, then 「使用此密码」 fills the form", async () => { + const { user, backend } = renderGenerator(); + await ready(); + await user.click(nav().getByRole("button", { name: /^密码/ })); + await screen.findByTestId("page-passwords"); + await user.keyboard("{Control>}n{/Control}"); + const form = within(screen.getByRole("dialog", { name: "新建登录项" })); + await user.type(form.getByRole("textbox", { name: "网站" }), "example.com"); + await user.click(form.getByTestId("item-form-generate")); + let generator = within(screen.getByRole("dialog", { name: "生成密码" })); + const password = await made(generator); + expect(lastCall(backend, "generator_generate")).toMatchObject({ domain: "example.com" }); + expect(generator.getByTestId("generator-rules")).toHaveTextContent( + "已按 example.com 的密码规则生成", + ); + expect(generator.queryByTestId("generator-copy")).toBeNull(); + await user.click(generator.getByTestId("generator-copy-icon")); + expect(lastCall(backend, "generator_copy")).toBeDefined(); + expect(generator.getByTestId("generator-history-open")).toHaveTextContent("生成历史 · 1 条"); + + await user.click(generator.getByTestId("generator-history-open")); + const verify = within(await screen.findByRole("dialog", { name: "验证身份" })); + await user.type(verify.getByLabelText("主密码"), `${MOCK_PASSWORD}{Enter}`); + const history = within(await screen.findByRole("dialog", { name: "生成历史" })); + expect(history.getByTestId("generator-history")).toHaveTextContent(password); + expect(history.getByTestId("generator-history")).toHaveTextContent("用于 example.com"); + expect(history.getByTestId("generator-history-countdown")).toHaveTextContent( + "120 秒后自动隐藏", + ); + await user.click(history.getByRole("button", { name: "完成" })); + + generator = within(screen.getByRole("dialog", { name: "生成密码" })); + expect(generator.getByTestId("generator-password")).toHaveTextContent(password); + await user.click(generator.getByTestId("generator-use")); + expect(screen.queryByRole("dialog", { name: "生成密码" })).toBeNull(); + expect(form.getByTestId("item-form-password")).toHaveValue(password); + }); + + it("clears the history from the dialog's list", async () => { + const { user, backend } = renderGenerator(); + await ready(); + await user.click(nav().getByRole("button", { name: /^密码/ })); + await screen.findByTestId("page-passwords"); + await user.keyboard("{Control>}n{/Control}"); + const form = within(screen.getByRole("dialog", { name: "新建登录项" })); + await user.click(form.getByTestId("item-form-generate")); + // The generator's dialog, drawn again each time the history gives way to it. + const generator = () => within(screen.getByRole("dialog", { name: "生成密码" })); + await made(generator()); + const open = async () => { + await user.click(generator().getByTestId("generator-history-open")); + const verify = within(await screen.findByRole("dialog", { name: "验证身份" })); + await user.type(verify.getByLabelText("主密码"), `${MOCK_PASSWORD}{Enter}`); + return within(await screen.findByRole("dialog", { name: "生成历史" })); + }; + // Nothing copied yet: an empty history, nothing to clear. + let history = await open(); + expect(history.getByText("还没有生成过密码")).toBeInTheDocument(); + expect(history.getByTestId("generator-history-clear")).toBeDisabled(); + await user.click(history.getByRole("button", { name: "完成" })); + await user.click(generator().getByTestId("generator-copy-icon")); + history = await open(); + await user.click(history.getByTestId("generator-history-clear")); + await screen.findByRole("dialog", { name: "清空生成历史?这些密码不会再显示。" }); + // Esc closes the question only: the history stays. + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog", { name: "清空生成历史?这些密码不会再显示。" })).toBeNull(); + expect(screen.getByRole("dialog", { name: "生成历史" })).toBeInTheDocument(); + expect(count(backend, "generator_history_clear")).toBe(0); + await user.click(history.getByTestId("generator-history-clear")); + const confirm = within( + await screen.findByRole("dialog", { name: "清空生成历史?这些密码不会再显示。" }), + ); + await user.click(confirm.getByRole("button", { name: "清空" })); + expect(lastCall(backend, "generator_history_clear")).toBeDefined(); + // Back to the generator, its link counting none. + expect( + within(screen.getByRole("dialog", { name: "生成密码" })).getByTestId( + "generator-history-open", + ), + ).toHaveTextContent("生成历史 · 0 条"); + }); +}); diff --git a/apps/desktop/src/pages/Generator.tsx b/apps/desktop/src/pages/Generator.tsx new file mode 100644 index 00000000..ad92ecc7 --- /dev/null +++ b/apps/desktop/src/pages/Generator.tsx @@ -0,0 +1,103 @@ +// The generator page, a part of 密码 (its header's 「生成器」, Ctrl G, 「添加登录项 › 生成密码…」, the +// palette; the sidebar keeps 密码 current and the header leads back): the generator, making +// passwords for no site, with 「复制」 as its main action, then its history: how much it holds and +// how long, and the passwords themselves inline after the proof, hidden after two minutes or as +// soon as the page is left. A login's form keeps its own generator dialog, which hands the password +// to the form (features/items/GeneratorDialog.tsx); both draw the same panels. +import { GENERATOR_HISTORY_DAYS, GENERATOR_HISTORY_MAX } from "@lockra/shared"; +import { Button, PageHeader, Panel, useT, useUiState } from "@lockra/ui"; +import { useShell } from "../app/shell-state"; +import { + ClearHistoryButton, + ClearHistoryDialog, + GeneratorHistoryList, + HistoryCountdown, + useGeneratorHistory, +} from "../features/items/GeneratorHistory"; +import { GeneratorPanel, useGenerator } from "../features/items/GeneratorPanel"; + +export function Generator() { + const t = useT(); + const shell = useShell(); + const generator = useGenerator(); + return ( +
+ shell.navigate("passwords")} + data-testid="generator-back"> + {t("generator.page.back")} + + } + /> + + + + +
+ ); +} + +/** 生成历史: what it holds and keeps, then the passwords themselves after the proof. */ +function HistoryPanel() { + const t = useT(); + const { generator: kept } = useUiState(); + const history = useGeneratorHistory(); + const { shown } = history; + return ( + <> + + {t("generator.history.count", { n: kept.history_count })} + + ) : ( + <> + + + + + ) + } + data-testid="generator-history-panel"> +
+

+ {t("generator.history.kept", { + max: GENERATOR_HISTORY_MAX, + days: GENERATOR_HISTORY_DAYS, + })} +

+ {shown === undefined ? ( + + ) : ( + + )} +
+
+ {/* Outside the frosted card, whose blur would hold a fixed dialog inside it. */} + + + ); +} diff --git a/apps/desktop/src/pages/Passwords.test.tsx b/apps/desktop/src/pages/Passwords.test.tsx index f82b56a1..9b66f741 100644 --- a/apps/desktop/src/pages/Passwords.test.tsx +++ b/apps/desktop/src/pages/Passwords.test.tsx @@ -586,11 +586,12 @@ describe("Passwords", () => { expect(lastCall(backend, "item_copy")).toMatchObject({ part: { type: "password" } }); }); - it("opens the generator with Ctrl G: a new password on request, copied by Rust", async () => { + it("goes to the generator page with Ctrl G: a new password on request, copied by Rust", async () => { const { user, backend } = renderPasswords(); await ready(); await user.keyboard("{Control>}g{/Control}"); - const generator = within(await screen.findByRole("dialog", { name: "生成密码" })); + const generator = within(await screen.findByTestId("page-generator")); + expect(screen.queryByRole("dialog")).toBeNull(); await vi.waitFor(() => expect(generator.getByTestId("generator-password").textContent).not.toBe("—"), ); @@ -612,8 +613,24 @@ describe("Passwords", () => { expect(lastCall(backend, "settings_set")).toMatchObject({ settings: { generator: { kind: "passphrase" } }, }); - await user.click(generator.getByRole("button", { name: "取消" })); - expect(screen.queryByRole("dialog")).toBeNull(); + // Leaving the page takes the password off the screen. + expect(backend.calls.some((c) => c.command === "secret_view_closed")).toBe(false); + await user.click( + within(screen.getByRole("navigation", { name: "主导航" })).getByRole("button", { + name: /^验证码/, + }), + ); + expect(await screen.findByTestId("page-codes")).toBeInTheDocument(); expect(backend.calls.at(-1)).toEqual({ command: "secret_view_closed" }); }); + + it("goes to the generator page from the add menu's 「生成密码…」 (board 04-f)", async () => { + const { user } = renderPasswords(); + const page = await openPage(user); + await user.click(page.getByTestId("add-item-menu")); + await user.click(screen.getByRole("menuitem", { name: "生成密码… · Ctrl G" })); + expect(await screen.findByTestId("page-generator")).toBeInTheDocument(); + expect(screen.queryByTestId("page-passwords")).toBeNull(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); }); diff --git a/apps/desktop/src/pages/Passwords.tsx b/apps/desktop/src/pages/Passwords.tsx index f692650b..e98de75a 100644 --- a/apps/desktop/src/pages/Passwords.tsx +++ b/apps/desktop/src/pages/Passwords.tsx @@ -344,10 +344,13 @@ export function Passwords() { onKeyDown={onSearchKey} placeholder={t("passwords.search")} aria-label={t("passwords.search")} - className="w-[min(26rem,36vw)]" + // Narrower than the codes' (36vw): 「生成器」 sits beside it, and a 960 px window + // keeps room for the title. + className="w-[min(26rem,30vw)]" data-testid="passwords-search" /> )} + } @@ -553,7 +556,26 @@ function PasswordsFacts({ count, groups }: { count: number; groups: number }) { return facts.join(" · "); } -/** The page's main action (board 04-f): a new login, a generated password, an import. */ +/** 「生成器」 beside the add menu: the generator page, a part of 密码 (Ctrl G and 「生成密码…」 go + * there too). */ +function GeneratorButton() { + const t = useT(); + const shell = useShell(); + const label = t("shell.nav.generator"); + return ( + + ); +} + +/** The page's main action (board 04-f): a new login, the generator page, an import. */ export function AddItemMenu() { const t = useT(); const shell = useShell(); @@ -575,7 +597,7 @@ export function AddItemMenu() { ]; const onSelect = (id: string) => { if (id === "new") shell.open({ type: "item_new" }); - else if (id === "generate") shell.open({ type: "generator" }); + else if (id === "generate") shell.navigate("generator"); else shell.navigate("import"); }; return ( diff --git a/apps/desktop/src/shell/Shell.tsx b/apps/desktop/src/shell/Shell.tsx index 6267db3a..f11f2306 100644 --- a/apps/desktop/src/shell/Shell.tsx +++ b/apps/desktop/src/shell/Shell.tsx @@ -25,7 +25,13 @@ import { useActivityPing } from "../app/activity"; import { useCommands } from "../app/commands"; import { useDispatch } from "../app/dispatch"; import { useFileDrag } from "../app/drag"; -import { type PageId, ShellStateProvider, isPageId, useShell } from "../app/shell-state"; +import { + type PageId, + ShellStateProvider, + isPageId, + passwordsPage, + useShell, +} from "../app/shell-state"; import { useShortcuts } from "../app/shortcuts"; import { BrowserRequests } from "../features/browser/BrowserRequests"; import { EntryDialogs } from "../features/entries/EntryDialogs"; @@ -36,6 +42,7 @@ import { UpdateBadge, UpdateDialog } from "../features/update/UpdateDialog"; import { Backup } from "../pages/Backup"; import { Codes } from "../pages/Codes"; import { Export } from "../pages/Export"; +import { Generator } from "../pages/Generator"; import { Import } from "../pages/Import"; import { Passwords } from "../pages/Passwords"; import { SettingsDialog } from "../pages/settings/SettingsDialog"; @@ -63,8 +70,8 @@ function ShellLayout() { const dragging = useFileDrag(); const passwords = state.features.passwords; const logins = useItems(); - // Without password management (a release build, for now) the page is not offered. - const page = shell.page === "passwords" && !passwords ? "codes" : shell.page; + // Without password management its pages (密码, 生成器) are not offered. + const page = passwordsPage(shell.page) && !passwords ? "codes" : shell.page; const [collapsed, setCollapsed] = useState( () => globalThis.localStorage?.getItem(COLLAPSE_KEY) === "1", ); @@ -88,8 +95,9 @@ function ShellLayout() { palette: () => shell.setPaletteOpen(true), // Ctrl N adds what the page lists: a login on the passwords page, an account elsewhere. add: () => shell.open(page === "passwords" ? { type: "item_new" } : { type: "add_manual" }), + // Ctrl G: the generator page. generate: () => { - if (passwords) shell.open({ type: "generator" }); + if (passwords) shell.navigate("generator"); }, lock, settings: () => shell.open({ type: "settings", section: "general" }), @@ -282,7 +290,8 @@ function ShellLayout() { }}> { if (id === "sync") shell.open({ type: "settings", section: "sync" }); else if (isPageId(id)) shell.navigate(id); @@ -308,6 +317,7 @@ function ShellLayout() {
{page === "codes" && } {page === "passwords" && } + {page === "generator" && } {page === "import" && } {page === "export" && } {page === "backup" && } diff --git a/docs/site/passwords/index.md b/docs/site/passwords/index.md index cc5dc089..d84cdb7a 100644 --- a/docs/site/passwords/index.md +++ b/docs/site/passwords/index.md @@ -182,6 +182,20 @@ the list after the master password, Touch ID or Windows Hello, and hides it afte **Clear the history…** empties it. The history keeps the last 100 passwords, for 90 days, on this device only: it is not synced and not in backups. +### The Generator page + + The password generator is getting a page of its own, a part of +the Passwords page: **Generator**, beside **Add login** at the top of the Passwords page, will open +it, and so will **Add login › Generate a password…**, `Ctrl G` and the command palette; **Back to +Passwords** at its top goes back. The page shows the generator, making passwords for no site, with +**Copy** beside **Generate again**. Under it, **Generated passwords** says how many passwords the +history holds, which ones go into it and how long they are kept. **Show the history…** lists them +after the master password, Touch ID or Windows Hello, each with when it was made and the site it +was made for, and hides them again after two minutes or as soon as you leave the page; **Hide** +hides them sooner. **Clear the history…** asks before it empties the history, on this page and in +the generator of a login's form. **Generate…** in a login's form keeps opening the generator over +the form, with **Use this password** and **History**. + ## On the phone diff --git a/docs/site/zh/passwords/index.md b/docs/site/zh/passwords/index.md index da77ade1..37cd7eaa 100644 --- a/docs/site/zh/passwords/index.md +++ b/docs/site/zh/passwords/index.md @@ -102,6 +102,10 @@ Lockra 把每个密码保存在一个登录项中:标题、用户名和邮箱 复制生成的密码,或用它保存登录项时,这个密码会进入生成历史;浏览器扩展在注册表单中生成的每个密码也会进入生成历史。生成器中的「生成历史」需要输入主密码,或通过 Touch ID、Windows Hello 验证后才会打开,两分钟后隐藏;「清空历史…」会清空它。生成历史保留最近的 100 个密码,最长 90 天,只保存在这台设备上:不会同步,也不会写入备份。 +### 生成器页面 + + 密码生成器将有单独的页面,作为「密码」页的一部分:「密码」页顶部「添加登录项」旁边的「生成器」会打开它,「添加登录项 › 生成密码…」、`Ctrl G` 和命令面板也会打开它;页面顶部的「返回密码」回到「密码」页。这个页面显示生成器,生成的密码不针对任何网站,「复制」位于「重新生成」旁边。下方的「生成历史」说明其中保存了多少个密码、哪些密码会记入,以及保留多久。选择「查看历史…」,输入主密码或通过 Touch ID、Windows Hello 验证后,页面会列出这些密码,每个密码都注明生成时间和用于哪个网站;两分钟后,或离开这个页面时,列表会再次隐藏,「隐藏」可以提前隐藏列表。「清空历史…」会先要求确认,然后才清空生成历史;这个页面和登录项表单中的生成器都是如此。登录项表单中的「生成…」仍在表单上方打开生成器,其中保留「使用此密码」和「生成历史」。 + ## 在手机上 diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 4b7539a4..cd412023 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -137,6 +137,7 @@ export const en: Messages = { nav: { codes: "Codes", passwords: "Passwords", + generator: "Generator", import: "Import", export: "Export", backup: "Backup", @@ -511,6 +512,12 @@ export const en: Messages = { }, generator: { title: "Generate a password", + page: { + title: "Password generator", + subtitle: + "Make a random password or a passphrase. The passwords you copy go into the history below.", + back: "Back to Passwords", + }, mode: { label: "Kind", password: "Random", passphrase: "Passphrase" }, regenerate: "Generate again", copy: "Copy", @@ -545,9 +552,14 @@ export const en: Messages = { history: { open: { one: "History · {n}", other: "History · {n}" }, title: "Generated passwords", + count: { one: "{n} password", other: "{n} passwords" }, + kept: "A password goes into the history when you copy it or save a login with it, and so does every password the browser extension makes on a sign-up form. The history keeps the last {max}, for up to {days} days, on this device only: it is not synced and not in backups.", + view: "Show the history…", + hide: "Hide", empty: "Nothing generated yet", clear: "Clear the history…", clearConfirm: "Clear the history? These passwords will not show again.", + clearSubmit: "Clear", site: "For {site}", }, }, diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 73f2fc13..f792ab46 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -136,6 +136,7 @@ export const zhCN = { nav: { codes: "验证码", passwords: "密码", + generator: "生成器", import: "导入", export: "导出", backup: "备份", @@ -499,6 +500,11 @@ export const zhCN = { }, generator: { title: "生成密码", + page: { + title: "密码生成器", + subtitle: "生成随机密码或口令。复制的密码会记入下方的生成历史。", + back: "返回密码", + }, mode: { label: "类型", password: "随机密码", passphrase: "口令" }, regenerate: "重新生成", copy: "复制", @@ -533,9 +539,14 @@ export const zhCN = { history: { open: { one: "生成历史 · {n} 条", other: "生成历史 · {n} 条" }, title: "生成历史", + count: { one: "{n} 条", other: "{n} 条" }, + kept: "复制的密码、用来保存登录项的密码,以及浏览器扩展在注册页面生成的密码,都会记入生成历史。最多保留最近 {max} 个,最长 {days} 天,只保存在这台设备上,不会同步,也不会写入备份。", + view: "查看历史…", + hide: "隐藏", empty: "还没有生成过密码", clear: "清空历史…", clearConfirm: "清空生成历史?这些密码不会再显示。", + clearSubmit: "清空", site: "用于 {site}", }, }, diff --git a/packages/shared/src/mock-backend.ts b/packages/shared/src/mock-backend.ts index 44e70fc5..da7c5094 100644 --- a/packages/shared/src/mock-backend.ts +++ b/packages/shared/src/mock-backend.ts @@ -40,6 +40,7 @@ import { type ExportStarted, type ExportTarget, type FieldChange, + GENERATOR_HISTORY_MAX, type GeneratedPastView, type GeneratedView, type GeneratorOptions, @@ -2785,7 +2786,7 @@ export class MockBackend implements Backend { this.generatedHistory = [ { password, at_ms: this.now(), site }, ...this.generatedHistory.filter((g) => g.password !== password), - ].slice(0, 100); + ].slice(0, GENERATOR_HISTORY_MAX); this.state.generator = { history_count: this.generatedHistory.length }; } diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index 08fbc5e7..5bd9f5e1 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -114,6 +114,13 @@ export function defaultGeneratorOptions(): GeneratorOptions { }; } +/** The most passwords the generator's history keeps (lockra-core `GENERATOR_HISTORY_MAX`). */ +export const GENERATOR_HISTORY_MAX = 100; + +/** How long the generator's history keeps a password, in days (lockra-core + * `GENERATOR_HISTORY_DAYS`). */ +export const GENERATOR_HISTORY_DAYS = 90; + /** The order of the password list (lockra-core `ItemSort`); favourites always come first. */ export const ITEM_SORTS = ["name", "recent", "modified", "added"] as const; export const itemSortSchema = z.enum(ITEM_SORTS); diff --git a/scripts/smoke/desktop.py b/scripts/smoke/desktop.py index 4f6cb93c..be5a3fb9 100644 --- a/scripts/smoke/desktop.py +++ b/scripts/smoke/desktop.py @@ -591,6 +591,12 @@ def run(self): self.nav(label) self.page(page) self.shot(f"{page}-{width}-{theme}") + # 生成器 is a part of 密码, with no sidebar row: its button in 密码's header. + self.nav("密码") + self.page("passwords") + web.click(web.wait('[data-testid="passwords-generator"]')) + self.page("generator") + self.shot(f"generator-{width}-{theme}") self.resize(1280, 800) self.settings(theme="light") self.focus()