Commit graph

608 commits

Author SHA1 Message Date
le king fu
d11337ccf6 docs(onboarding): changelog, architecture, user guide and interactive QA checklist (#357)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m54s
Closes phase 1 of the onboarding wave (#351-#356) on the documentation side.

- CHANGELOG.md / CHANGELOG.fr.md: Added (guided empty states, getting-started
  checklist, sample folder, bank CSV export guide) and Changed (re-show button
  in Settings > Users, Import "?" popover linking to the guide) under
  [Unreleased] / [Non publié], same entries in both languages.
- docs/architecture.md: create_sample_import_folders (fs_commands 6 -> 7,
  total 36 -> 37) with its contract, onboardingService, useOnboardingChecklist,
  the onboarding_checklist_hidden preference, the samples/ corpus next to csv/,
  and an "Onboarding" section (four empty states and their signal, source of
  truth of the six checklist rows, sample-folder flow). Tree counts measured
  for the directories the wave touched.
- docs/guide-utilisateur.md chapters 3 and 11 + docs.dashboard.* and
  docs.settings.* keys (FR/EN). Hand-off from #355: docs.gettingStarted.steps
  aligned with the six-step quick start of the markdown, docs.import.steps
  mentions the sample folder, so the three guide copies agree.
- docs/qa-onboarding.html: interactive manual-test checklist, French only,
  static, no network dependency, state in localStorage, plain-text report to
  the clipboard with a visible fallback, reset button.

Resolves #357

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 20:25:55 -04:00
le king fu
624eef7ba8 feat(onboarding): Import empty states, sample folder button and guide links (#356)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m59s
The import page without a source no longer shows a bare "no source found"
card. `ImportEmptyState` (built on the shared `EmptyState`) takes the place
of the empty `SourceList` in both situations: without an import folder it
offers "Choose a folder" as the single primary action; with a folder and no
bank sub-folder it offers "Create a sample folder" (primary, disabled while
in flight) and "Refresh" (secondary). Both variants, and the "?" help
popover through the new optional `linkTo` prop of `PageHelp`, link to the
bank CSV export guide at `/settings/users#import-export`. `SourceList`
stays an untouched pure list.

`useImportWizard` gains `createSampleFolders`: it invokes the Rust command
`create_sample_import_folders` with the folder and the webview's LOCAL date
(built from the local getters, not `toISOString()`), rescans the folder so
the two cards appear without another click, then raises a closable success
banner from the new `sampleCreated` state, which `reset` and a folder change
clear. A refusal from Rust (a sample folder already exists) lands in the
existing error banner. The log line carries the folder NAMES only, never
the absolute path, since the logs travel with a feedback report.

"Browse" on the folder card drops to the secondary style while no folder is
configured, so a single primary button is on screen. The reducer and its
initial state are exported as `wizardReducer` / `initialWizardState` and
unit-tested; `ImportEmptyState` is tested by direct call.

Resolves #356

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 20:10:25 -04:00
le king fu
e0ee09bca7 feat(docs): sub-sections in the user guide and bank CSV export guides (#355)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m53s
Add an optional `subsections` array to a user-guide section, rendered by
the new pure `DocsSubsections` component between the "How to" block and
the tips. Each entry has an id, a title, an optional italic status line,
an ordered list of steps and an optional trailing note. The ids are
registered in `sectionRefs` of `DocsContent`, so the existing hash
navigation (`/settings/users#import-export`) reaches the sub-sections
too. Only the Import section declares the key; the eleven other
sections render exactly as before.

The Import section gets six blocks in French and English: a generic
"export a CSV from your bank" block, then Desjardins, Tangerine, RBC,
National Bank and BMO with their verification status. The National Bank
and BMO blocks, which ask the user to send a header line through the
feedback form, carry the warning never to paste an account number, a
balance or a real transaction, in the JSON files and in the markdown
guide alike.

`docs/guide-utilisateur.md` receives the same six blocks in chapter 4,
a mention of the "Create a sample folder" button in its "How to", and a
"Quick start" in chapter 1 rewritten as the six steps of the
getting-started checklist, same order and wording as
`onboarding.checklist.items.*`.

`DocsSubsections.test.tsx` calls the component directly (no jsdom):
null for a section without sub-sections, one block per entry with its
id and ref registration, status line and note rendered only when
present, malformed entries dropped.

Resolves #355

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 20:00:09 -04:00
le king fu
e4a0f1042e feat(onboarding): create_sample_import_folders command, sample CSV templates and detection test (#354)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
PR Check — Rust / rust (pull_request) Successful in 3m38s
Add a Tauri command that writes two synthetic bank statements (Desjardins
chequing account, Tangerine credit card) into the import folder, one
sub-folder each, so a new user has sources to import on the first run.

The command takes the import folder and the webview's local date only
(chrono is compiled without `clock`), renders the `{{M1}}` / `{{M2}}`
tokens as the two complete months before that date, names each file
`releve-YYYY-MM.csv` after the most recent one, and refuses to run when
the folder is missing or when either sample folder already exists -
checked before anything is written. It returns the two folder names.

The templates live under `src/__fixtures__/samples/` so the Rust command
(`include_str!`) and vitest read the same bytes. The integration test
proves detection recognises each file as its bank with a signed single
amount and reads 40/40 rows, that the production `mapRow` parses every
row and sums to +740.00 / -368.46, and that the 180 seed keywords
categorise exactly 33 / 36 lines while leaving the hand-classified ones
untouched. The frozen detection corpus (`src/__fixtures__/csv`) is not
modified.

Five Rust tests cover the missing folder, the existing folder, the
success path, an unparseable date and the year boundary, each in its own
sandbox so they run in parallel.

CHANGELOG and architecture docs are centralised in #357.

Resolves #354

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:50:41 -04:00
le king fu
8eac47f69b feat(onboarding): empty states on the Transactions and Budget pages (#353)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m51s
A profile without any transaction now gets an "import first" card with a
button to the import page on both pages, in place of the filter bar, the
table and the pagination (Transactions) and of the filter panel and the
grid (Budget). Title, page help and template actions stay.

The signal is a profile-wide `totalInDb` count loaded once at mount via
`getTransactionCount()`, not `rows.length`: the default Transactions
filter is "since January 1st", so an empty page cannot tell an empty
profile from a year without transactions. `null` (not loaded yet) keeps
the regular layout, and so does a loading Transactions page.

`transactionsReducer` / `initialTransactionsState` and `budgetReducer` /
`initialBudgetState` are exported with a pure `showsEmptyState` selector
per hook, so the reducer tests pin that the count never touches filters,
sort or page and that 0 then N > 0 brings the table back. The existing
"no transactions found" / "no categories found" rows are unchanged.

Resolves #353

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:40:45 -04:00
le king fu
3f3531392e refactor(onboarding): name the checklist preference states after what they hold (#352)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m42s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:34:24 -04:00
le king fu
a060f0e0e0 feat(onboarding): dashboard empty state, getting-started checklist and re-show button (#352)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
A profile without any transaction now opens a dashboard that says what to
do: a welcome card with the expanded getting-started checklist and one
primary action towards the import page, instead of widgets showing 0 $.
With transactions, the compact checklist sits between the title and the
filter panel until it is complete or dismissed.

- `EmptyState` (pure, dashed card from the import source list; a `to`
  action renders a react-router `<Link>`, an `onClick` action a button)
- `OnboardingChecklist` (pure, expanded and compact modes; undone rows
  link to their route, the locked Budget row shows a lock, the "Base and
  up" label and links to the license card; neutral budget row and hidden
  counter until the entitlement is known)
- `DashboardEmptyState` composes both
- `DashboardPage` branches on the hook `status`: loading renders the title,
  help and banner only; error falls back to the v0.15.0 page without the
  list and logs a warning; ready with zero transactions renders the empty
  state outside the `isLoading` veil
- `useOnboardingChecklist` exposes `counters` and `error` for the page
- Users settings: "Show the getting-started checklist" button in the Guide
  section, visible only when the preference is set, with an inline
  confirmation after the click
- FR + EN keys `dashboard.empty.*`, `onboarding.checklist.*`,
  `settings.users.onboarding.*`

Tests: `EmptyState.test.tsx` and `OnboardingChecklist.test.tsx` by direct
call (FilterPanel pattern). No DB migration. CHANGELOG left to #357.

Resolves #352

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:32:43 -04:00
le king fu
76fd8d376a refactor(onboarding): compute the load error message once (#351)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
Self-review tidy: `errorMessage(e)` was evaluated twice in `refresh`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:23:43 -04:00
le king fu
f77ce94505 feat(onboarding): add transaction count, onboarding counters and checklist reducer (#351)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
Lay the data foundation of the getting-started checklist, without UI:

- transactionService.getTransactionCount(): unfiltered COUNT(*) of transactions
- onboardingService: getOnboardingCounters() reads the five aggregated counts,
  the import folder and the onboarding_checklist_hidden preference in one
  Promise.all; hideChecklist()/showChecklist() persist the preference and log
- utils/onboardingChecklist.computeChecklist(): pure computation of the six
  rows (profile, importFolder, source, import, manualCategory, budget), with
  the budget row locked and excluded from the total when the edition has no
  Budget
- hooks/useOnboardingChecklist: exported reducer + initial state, actions
  refresh/hide/show, composed with useEntitlement("budget"); a load error
  sets status "error" and keeps the hidden flag
- types OnboardingCounters, ChecklistItem, ChecklistItemKey

Tests cover every row in isolation, the budget gating (total 5 vs 6, complete
at 5/5 locked and 6/6 open), the aggregated query and preference reads, and
every reducer action. No DB migration, no CHANGELOG entry (centralised in #357).

Resolves #351

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:22:18 -04:00
le king fu
7a7fdf90e2 state: sync after #234 (PR #375 merged)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:28:11 -04:00
le king fu
f821492274 ci: document the cache evictions and key inputs found in review (#234)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m45s
PR Check — Rust / rust (pull_request) Successful in 3m21s
Applies the three suggestions of the review of #375:

- The runner's cleanup keeps one entry per key, whatever its scope: a PR
  run that saves under the shared entry's key evicts it for every PR until
  the next Monday, and two PRs on the same non-main lockfile evict each
  other. Added to the list of cold runs.
- The cache key also hashes the job's CARGO*/RUST*/CC*/CFLAGS*/CXX*/CMAKE*
  variables, and ignores the app's own version: changing env: means a cold
  run until Monday, a release does not.
- A Monday restore that fails with a 404 after a quiet week is a known
  runner race (forgejo/runner#1741), not #234 coming back.

Refs #234

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:04:50 -04:00
le king fu
20db766f52 docs: describe the Rust cache, its weekly writer and the absent npm cache (#234)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
PR Check — Rust / rust (pull_request) Successful in 3m22s
CLAUDE.md and docs/architecture.md still said the Actions cache was
removed until #234 was fixed. They now describe rust-cache, the per-PR
isolation of what a pull_request run saves, the weekly run on main that
writes the shared cache, the expected cold runs, and why check-frontend
has no npm cache. The audit.yml paragraph no longer quotes the outdated
"1 PR in 40" figure.

Refs #234

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:22:08 -04:00
le king fu
cf1da2bea0 ci: restore the Rust cache with rust-cache and a weekly run on main (#234)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
PR Check — Rust / rust (pull_request) Successful in 9m37s
The runner's cache server is reachable again since the VPS fix of
2026-10-03 (cache proxy pinned to port 28100, ufw rule scoped to the
Docker job networks; diagnosed in #374). Bring caching back to check-rust
through Swatinem/rust-cache, as #232 deferred.

forgejo-runner v12.7.0 keeps what a pull_request run saves private to
that PR, so PR runs alone would only warm later pushes to the same PR. A
weekly scheduled run on main (Mondays 06:30 UTC) and workflow_dispatch
write the shared entries that every PR falls back to.

rust-cache v2.9+ declares node24 and the runner starts JavaScript actions
with the container's node, so the job now installs Node.js 24. zstd is
installed for faster, smaller cache archives.

No npm cache in check-frontend, by decision: with per-PR isolation it
would only serve later pushes of a PR, and npm ci takes 9s there. audit.yml
loses two comments this change makes false.

No CHANGELOG entry: CI configuration only.

Refs #234

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:09:00 -04:00
le king fu
0e4fef5ad7 state: record the v0.16.0 tests (#365 closed, #370, #372, #373)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:48:49 -04:00
le king fu
1923f8f70b state: sync after the v0.16.0 release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:22:30 -04:00
le king fu
56fc612598 chore: release v0.16.0
All checks were successful
Release / build-and-release (push) Successful in 21m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:58:06 -04:00
le king fu
6625d252e1 state: sync after #365 (PR #368 merged)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:45:10 -04:00
le king fu
b107cf42ce fix(license): apply the review of #368
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m45s
PR Check — Rust / rust (pull_request) Successful in 8m54s
- A state file that cannot be written now has a test on both of its paths:
  the attempt (nothing is sent, or a read-only data directory would send a
  request at every start) and the verdict (the key is not reported revoked
  when nothing on disk says so). The first path was correct but unguarded:
  the review's mutation left all 185 tests green.
- The storage outcome no longer claims that nothing was sent: it also covers
  a verdict that could not be written. Reworded in licenseService.ts, the
  in-app log line and docs/architecture.md.
- The Updates card names a revoked key in its Free branch, between the
  message and the CTAs: a refunded Base buyer first meets the loss of
  automatic updates there (Max, 2026-10-03). The error page stays as it is.
- A revoked key keeps being checked once per 24 h while it stays stored, so
  that a correction made on the license server is picked up (Max,
  2026-10-03). Declared in the user guide, the in-app docs, the changelogs
  and the ADR 0017 amendment.

Refs #365

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:29:47 -04:00
le king fu
4225dadebe test(license): guard what the first mutation pass left green (#365)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m3s
Five mutations survived the first pass, for four gaps:
- the revoked flag exposed by the provider: the value is now derived by
  licenseContextValue, a plain function, and tested;
- the price message for a revoked license missing from either locale;
- the attempt written after the request instead of before: a test now reads
  the state file from the server's side, while the request is in flight;
- the 24 h interval, which the boundary rows followed because they were
  built from the constant: both sides of the boundary are written out.

Refs #365

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:10:24 -04:00
le king fu
e24c30fe6a feat(license): online revocation check — a revoked key falls back to Free (#365)
A refunded license was revoked on the server but kept working in the app:
the edition is resolved offline from the key alone, and the flag added by
#347 lasted the session only and never touched the edition.

Rust
- license.check, next to the key, holds what the license server last said
  about it: {key_sha256, attempted_at, revoked}. Never the key itself. A
  missing, unreadable or invalid file, or a state about another key, reads
  as "no state": not revoked, check due.
- entitlements_from_dir resolves a revoked key to ("free", []), which also
  locks auto-update. read_license and store_license report the state.
- check_license_revocation: POST /v1/licenses/verify with the product and
  the key only. Nothing is sent without a stored key that validates, nor
  within 24 h of the last attempt, which is written before the request.
  Fail-open: only "valid" lifts the state, only "revoked" sets it.
- Activation, machine list and deactivation record a license_revoked answer
  under the key they sent.
- fetch_price reads error.code of a 403 and names license_revoked instead of
  premium_required. It records nothing: the price route judges the license
  of the activation token, which the client cannot tie to the stored key,
  and 0.15.0 left the token of a previous key next to a new one (#367).

Front
- LicenseContext takes the revoked state from Rust (info.revoked); the
  session flag and markRevoked are gone. The check starts once per app
  start, writes its outcome to the in-app log, and reloads the context only
  when the state changed. A revoked key entered again stays Free.
- UpsellGate and the License card name the revocation.

Docs: ADR 0017 amendment (D13 replaced, the first unsolicited request and
its bounds), architecture, user guide, price contract annex, changelogs.

Refs #365

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:45:34 -04:00
le king fu
a9fa34dd54 state: sync after #350 (PR #366 merged)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:26:45 -04:00
le king fu
0a9c9f71dd fix(license): apply the review of #366
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m47s
PR Check — Rust / rust (pull_request) Successful in 8m46s
The review approved the previous head with five suggestions; this commit
applies the four that touch the repository (the fifth is the manual check
in the pull request description).

- Tests: a valid token issued for another machine goes with any row of
  this machine. Two rows added to the deactivation table: the reviewer's
  mutation that kept such a token stayed green before them.
- ADR 0020, removal condition: counted from the last raw-claim token the
  server issued, not from a client version. A Free 0.15.0 no longer
  updates and can still activate with the raw id months from now.
- ADR 0020: names what the transition leaves behind when a machine is
  activated again while its raw row is still active (a second row, one
  more slot), and what a public key does not cover (whoever already holds
  the OS id can recompute the derived one).
- "No command hands the OS id to the webview" was wider than the code: the
  server's list still carries it for a row from before the derivation.
  Bounded in the ADR, architecture.md and the licenseService.ts comment.

No behaviour change. Rust 168, vitest 1314.

Refs #350

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:12:32 -04:00
le king fu
e5662ad018 fix(license): send a derived machine identifier, never the OS machine id (#350)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 9m19s
Activation sent `machine_uid::get()` as is: /etc/machine-id on Linux,
MachineGuid on Windows. machine-id(5) calls that id confidential and asks
applications to send a keyed hash under a fixed, application-specific key
instead. The license server stores what it receives next to the license
email and the host name, lists it back and signs it into the activation
token.

- derive_machine_id: HMAC-SHA256 under a frozen key, 64 lowercase hex
  characters, pinned by two reference vectors. An empty OS id is refused,
  never hashed.
- Activation sends the derived identifier and requires the issued token to
  carry it. Nothing else originates a machine identifier.
- A machine activated before this change stays activated: a valid token
  whose claim is the raw id of this machine still reads "Activated" (D1).
- The machine list flags `is_this_machine` in Rust, for a row under either
  identifier (D3). `ActivationStatus` no longer carries an identifier and
  the unused `get_machine_id` command is removed: no command hands the OS
  id to the webview any more.
- After a deactivation the local token is dropped when it stood for the
  freed slot: any row of this machine takes it, except the old raw row
  while the token stands for the derived one.
- ADR 0020 records the derivation, the transition and the condition for
  removing the raw-id compatibility.

Behaviour changes beyond the issue text: the machine list and a
deactivation no longer fail on an unreadable OS id (best effort), the
activation status then reads "not activated" instead of failing, and the
unused `store_activation_token` command refuses a token issued for the
raw id.

No new dependency (hmac and sha2 were already declared); Cargo.lock is
untouched. Rust 151 -> 168 tests, vitest 1311 -> 1314.

Refs #350

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:52:48 -04:00
le king fu
c10173086e state: resync after the payment go-live (la-compagnie-maximus#141, maximus-api#62)
The site checkout has been live since 2026-09-29 and the control
purchase of 2026-09-30 proved the chain down to revocation on refund,
so "no tag before the go-live" no longer has a reason to exist: the
desktop release is now a dependency of the go-live issue.

Also records that maximus-api#63 and maximus-api#70 are deployed and
that the terms of sale of la-compagnie-maximus#157 are published, adds
the spec-onboarding-phase1 milestone to the backlog, and moves the
latest decision entry from 2026-10-01 (UTC) to 2026-09-30, the local
merge date used by every other entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:21:14 -04:00
le king fu
ce408bfb7d docs: add the onboarding analysis behind spec-onboarding-phase1
The seven issues of the spec-onboarding-phase1 milestone cite
docs/analyse-onboarding-2026-09.md as their source analysis, but the
file had stayed untracked since 2026-09-24 and only existed on one
machine. Phases 2 and 3 of the onboarding work are described nowhere
else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:21:14 -04:00
le king fu
93a56b1f9a state: sync after #347 (PR #364 merged)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:58:41 -04:00
le king fu
cdf4df85f7 fix(license): apply the second review of #364
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m15s
- Key entry and machine activation can no longer overlap. An activation
  in flight writes the token of the key it read: a key stored meanwhile
  ended up next to the previous key's token, the dead end 3f90635
  closes. "Enter a key" and the form's submit are disabled while an
  activation runs (LicenseKeyForm gains a busy prop), and "Activate"
  while a key is being stored.
- The "Activated" badge is hidden for a revoked key: the server
  deactivated every machine of the license.
- ADR 0017: the #347 amendment also points at section 1, which still
  mentions the machine-binding downgrade.

Refs #347

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:44:51 -04:00
le king fu
3f90635298 fix(license): apply the review of #364
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
PR Check — Rust / rust (pull_request) Successful in 10m35s
- store_license_in drops activation.token when the stored key changes.
  The token names the previous license to the price server (its sub is
  the license id, the key carries the email), so the card showed the
  machine as activated while prices were refused for the old license.
  The same key entered again keeps its token; a refused key changes
  nothing.
- get_activation_status goes through activation_status_in(dir,
  machine_id, key), tested in a temporary directory: a missing,
  expired, foreign-machine, foreign-key or unreadable token reads as
  not activated. It alone decides "Not activated" and "Activate".
- LicenseCard is keyed by licenseIdentity(info): another key remounts
  the body, which re-reads the activation and drops the previous key's
  machine list.
- invalidSignature asks to check the copy first. jsonwebtoken checks
  the signature before decoding the payload, so a damaged copy often
  reads as a signature failure (characterization test). User guide and
  changelogs aligned.
- Render tests: the other errors stay visible under Machines, and the
  edition's green check gives way to an alert for a revoked key.

Refs #347

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:58:22 -04:00
le king fu
d2ded515fd fix(license): name key-entry errors, keep the edition off the activation token, flag revoked keys
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m11s
Rust (license_commands.rs)
- LicenseError gains InvalidPrefix, InvalidSignature, Malformed and
  UnknownEdition. validate_with_key returns it (ExpiredSignature maps
  to LicenseExpired), and store_license, read_license and
  validate_license_key reject with the same JSON as the network
  commands. store_license_in and read_license_in take a directory, so
  the Storage mapping is tested at the source.
- D12: the edition comes from the license key alone.
  resolve_license_entitlements no longer takes the activation token,
  and current_entitlements reads only license.key
  (entitlements_from_dir). A token that had expired (2-year server
  lifetime) or came from another machine (restored data) used to drop
  a valid key to free, while a key copied without its token kept its
  edition. get_activation_status and persist_activation_token keep the
  strict token validation, and the key keeps its strict validation
  (CWE-613).

Front
- LicenseCard names the load error, and LicenseKeyForm (extracted so a
  static render reaches it) names a refused key, through
  parseLicenseError: no raw backend string reaches the card.
- D13: a license_revoked answer is recorded in LicenseContext for the
  whole session, because the card unmounts when the user leaves
  Settings > Users. Only a different key clears it. Card-level
  message, alert icon, Activate disabled through selectCanActivate.
- Machine counter n/limit, "Not activated", activating and
  deactivating labels, dates in fr-CA/en-CA, and "No expiry date"
  beyond 50 years (licenseExpiry.ts, for the 100-year keys of
  maximus-api#63).
- license.removeLicense and license.machines.activateError removed;
  the price authFailed message points to activating the machine.

Tests: 15 new Rust tests (resolution from a data directory, named
variants, the 100-year key of #363, a key without exp) replace 3
machine-binding tests; vitest covers the card and form renders, the
context revocation, the hook wiring and the locales.
test: reproduces #347 (an expired token of this machine resolved a
valid Base key to free on 5d549d6).

Docs: CHANGELOG FR/EN, guide section 12 and in-app docs,
architecture.md, ADR 0017 amendment for #347, qa-update-cycle.md,
api-contract-prices.md.

Fixes #347
Fixes #360
Refs #363

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:39:01 -04:00
le king fu
5d549d674f state: sync after #346 (PR #362 merged)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 20:52:47 -04:00
le king fu
170b22d356 fix(upsell): apply the review of #362
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m39s
- Render tests for the upsell CTAs (UpsellCtas.render.test.tsx): the
  reviewer's probe, which needs no jsdom (react-dom/server in the node
  environment, plus the FilterPanel.test.tsx pattern of calling a
  component as a function). It pins the Base/Premium CTA on UpsellGate,
  the router guard of HaveKeyButton, the localized product URL, the
  Premium note and the onNavigate forwarding, none of which the suite
  guarded before.
- A failure to open the browser (openUrl) is now logged through
  logError in PurchaseButton, DownloadLatestButton and LicenseCard,
  instead of vanishing as an unhandled rejection that never reaches the
  in-app logs. Tested for both shared buttons.
- upsellCtaFor: comment the trap in making Premium purchasable, since
  PurchaseButton sells Base only.
- ADR 0017 amendment: the Account card text holds in practice only;
  current_entitlements still grants Premium to an active subscription,
  a dormant path until a subscription is sold.
- User guide and in-app docs: the purchase step now comes before the
  key entry, and the Balance button is named "Get Premium".
- CHANGELOG: "Maximus Account" in English, "every screen that requires
  it" in both files (the Balance screen sells nothing), and the French
  elision before a guillemet.

Refs #346

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 20:47:04 -04:00
le king fu
57a0fbc2f7 feat(upsell): activate "Get Base", honest Premium note, purchase path in Free, Account card text
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m42s
- upsellCtaFor(tier) decides what a locked screen offers: Base is
  purchasable, Premium stays disabled with "Premium isn't sold online
  yet". Pure and tested on the whole entitlements matrix.
- Shared CTAs, one component per file: PurchaseButton ("Get Base") and
  DownloadLatestButton open the localized product page (purchaseUrl) and
  need no router; HaveKeyButton renders only inside BrowserRouter.
- UpsellGate uses them; the purchase link never calls onNavigate, so
  modal hosts stay open. ProfileFormModal reuses UpsellGate instead of
  its compact panel, keeps its Cancel button, and closes after "I
  already have a key" (onNavigate={onClose}).
- In Free, the Updates card offers Get Base, I already have a key and
  Download the latest version; the error page (outside the router)
  offers Get Base and the download link. No update check, no network
  call.
- i18n FR/EN: Account card text, Premium note, Updates notEntitled text,
  download label, editions guide step and tip; the "coming soon" note
  key is gone. Tests pin the upsell key set and the decided texts.
- Docs: ADR 0017 amendment, architecture (UpsellGate row, shared CTAs,
  upsellCtaFor, multi-profile row, counts), user guide section 12;
  CHANGELOG FR/EN.

Proven red: Premium made purchasable 2, balance sold as Base in the
matrix 4, the old note key back in a locale 1, Account text altered 1,
download label altered 1.

Fixes #346

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 15:50:45 -04:00
le king fu
de274ec85e state: sync after #270 (PR #359 merged)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 09:17:07 -04:00
le king fu
0c9b8f8f1a fix(license): apply the third review of #359
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m11s
- The Activate button also stays disabled while a deactivation runs
  (selectCanActivate): an activation racing the deactivation after a
  409 could reach the server first, clear the machine-limit message and
  leave the next click to a 429.
- activate_body builds the activation request and omits machine_name
  when the host name is unreadable; the server answers 400 invalid_input
  to a null. A pure test pins it.
- A deactivation whose 2xx body cannot be read stays an invalid_response.
  Counting it as a success would make the command delete this machine's
  local token for nothing. A mock-server test pins it.

Proven red on the fixed tree: Activate live during a deactivation 1,
machine_name sent as null 1, unreadable 2xx body read as empty 1.

The same review found that maximus-api answers 500 to the reactivation
of a machine deactivated before (unique constraint without is_active):
tracked in maximus/maximus-api#70, outside this PR.

Refs #270

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 09:02:08 -04:00
le king fu
b8e67c3d4f fix(license): apply the second review of #359
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 8m54s
- The idempotent-deactivation test now pins the route's other 404:
  license_not_found stays an error. The previous negative case was a
  403 that the deactivate route never returns, so swallowing
  license_not_found (an unknown key shown as deactivated, the local
  token deleted) went unnoticed.
- A test deactivates this machine and checks that the re-read activation
  status brings the Activate button back. Removing that re-read used to
  leave the suite green.
- The Activate button stays disabled while the list loads
  (selectCanActivate), the counterpart of selectCanDeactivate: a verify
  answer landing after a successful activation can no longer mark as
  fresh a list that misses this machine.
- CHANGELOG: the old /simpl-resultat address is redirected by the site
  since la-compagnie-maximus#153, so the "Buy" bullet now says the
  button opens the product page directly, in the app's language. The
  test comment is moved to the past tense.

Proven red on the fixed tree: idempotence swallowing license_not_found
1, no status re-read after deactivation 1, Activate live during a list
load 1.

Refs #270

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 08:09:01 -04:00
le king fu
a051b96c87 fix(license): apply the review of #359
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
PR Check — Rust / rust (pull_request) Successful in 8m48s
- A 404 without a known code (missing route, proxy error page) is now
  pinned as a server error by two table rows, so "key not found" can
  never be shown for it.
- The message-key test spells out the expected key of every code
  instead of comparing the map with itself, and checks the keys are
  distinct: swapping two entries now fails.
- Idempotent deactivation moves into deactivate_idempotent, without
  AppHandle, tested against a mock server (404 no_active_activation ->
  done, 403 revoked -> error).
- Deactivate buttons stay disabled while the list reloads
  (selectCanDeactivate), so a machine deactivated during a reload
  cannot reappear.
- The rate-limit message uses i18next plural forms (rateLimit_one /
  rateLimit_other with count): no more "1 seconds".
- CHANGELOG: drop the bullet about the edition updating after an
  activation. The edition does not change with or without an
  activation token, so nothing is observable; refresh() stays.

Proven red on the fixed tree: 404 without a known code read as
LicenseNotFound 1, license_expired mapped to the revoked key 2,
idempotence removed 1, buttons live during a reload 1, {seconds}
instead of {count} 2.

Refs #270

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:27:43 -04:00
le king fu
7d667f7ca3 fix(license): call /v1 routes with product, name server refusals, localize purchase URL
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m40s
PR Check — Rust / rust (pull_request) Successful in 8m52s
- Activation, deactivation and verify POST to /v1/licenses/* with
  "product":"simpl-resultat" instead of the legacy /licenses/* paths,
  which cost a 308 on every call.
- New LicenseError enum, serialized as {code} like FetchPriceError and
  read from error.code of the /v1 envelope. A 200 {valid:false} from
  verify is now a coded refusal instead of an empty machine list, and an
  HTML error page on a non-2xx maps to server instead of
  invalid_response. Deactivation is idempotent: no_active_activation
  counts as done.
- The three calls live in *_inner functions without AppHandle, tested
  against a mockito server that only declares the /v1 routes.
- useLicenseMachines hook with an exported reducer. The activation error
  survives the list load it triggers (machine_limit_reached opens the
  list), a deactivation removes the machine locally without re-running
  verify (4 requests on the machine-limit path, under the server's
  5/min/IP limit), and refresh() follows every successful activation or
  deactivation.
- license.errors.* in FR and EN; purchaseUrl() opens
  /{fr|en}/produits/simpl-resultat.

test: reproduces #270. Each original defect, reintroduced on the final
tree, turns tests red: legacy activate path 5, missing product 2,
envelope read as a string 3, verify valid:false as an empty list 2,
strict body parse on a non-2xx 2, untrimmed key 1, list load clearing
the activation error 4, verify re-run after deactivation 2, empty state
despite a list error 1, no refresh after activation 3, old purchase
URL 4.

Fixes #270

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 09:15:52 -04:00
le king fu
7b852c9969 state: sync after /plan-run paiements (planned-2026-09-24-paiements)
Desktop wave #270 -> #346 -> #347 and site #153 planned in twin
milestones, one /autopilot run per repository. Review found that the
2-year activation token silently drops activated machines to Free
(handled in #347). 100-year keys decided out of wave (maximus-api#63,
la-compagnie-maximus#157); machine id hashing tracked in #350.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 19:47:32 -04:00
le king fu
11e12ab7a3 docs(skills): add /issue-license and a release verification script
- issue-license (new): issue licenses by hand through maximus-api's admin
  endpoint (testers, gifts, refund compensation, admin account); the admin
  key and the issued keys never enter the transcript. Third-party
  beneficiaries are named by their Logto id, never by email: the
  repository is public.
- release step 0: when a release tightens edition gating, tester keys must
  be entered before the tag, since an install without a key receives that
  version and then nothing more.
- release step 9: scripts/verify-release.sh checks the 7 assets by suffix
  and the content of latest.json, instead of a script rewritten each time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:51:01 -04:00
le king fu
14f1d3b226 state: sync after #348 (rustls RUSTSEC-2026-0285, daily audit green again)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:36:59 -04:00
le king fu
9abf4743e5 docs(changelog): scope the "no behaviour change" claim to the app's own code
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 8m57s
Review note on #349: the entry said "no behaviour change", which holds
for this application's code but not for the TLS layer it links. rustls
0.23.45 closes a handshake that breaks RFC 8446 section 5.1 where 0.23.36
accepted it — that change of behaviour is the fix itself, and saying
otherwise understates what a reader is being told.

Changelog text only, in both languages. The lockfile is untouched, so
the CI evidence gathered on run 412 still stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:24:10 -04:00
le king fu
8ea3410c82 fix(deps): update rustls to 0.23.45 to clear RUSTSEC-2026-0285
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 9m17s
The daily audit has failed every morning since 2026-09-15 (runs 405 to
411, 7 consecutive reds). Nothing was pushed to main in that window —
the last commit is 97ee3c3 from 09-13. RUSTSEC-2026-0285 was published
on 09-14, after that morning's run, and the first red is the next day's.
Rustls accepted TLS 1.3 handshake messages sent at the wrong encryption
level when they followed a key-changing message inside the same record,
where RFC 8446 section 5.1 requires closing the connection instead.
Medium severity, confidentiality only: the handshake transcript stays
authenticated, so this cannot be used to alter or complete a handshake
from a network position. Patched in 0.23.45.

rustls is reachable on both shipped targets, checked per triple rather
than with --target all, by a single path: tauri-plugin-updater ->
reqwest 0.13.2, the automatic update check and download. That is
narrower than h2 in #343 — the app's own reqwest 0.12.28 goes through
hyper-tls and native-tls, so the licence, auth, price and feedback calls
were never on this path. A published fix plus a reachable crate is rule
1 of .cargo/audit.toml, so this gets fixed rather than suppressed; the
suppression list and the check-rust guard are both untouched.

rustls-webpki moves with it, 0.103.13 to 0.103.15, because 0.23.45
tightens its bound from ^0.103.5 to ^0.103.14. That is required rather
than collateral, and it keeps the four advisories #310 cleared by
pinning 0.103.13.

Unlike #344, no stanza was hand-written: `cargo update -p rustls
--precise 0.23.45` left the seven windows-sys pointers on 0.61.2
untouched, so cargo's own output was kept. That also narrows the premise
of #345 — the drift is triggered by hyper and what it pulls, not by the
TLS subtree at large, since rustls sits squarely in it and moved
nothing. 666 packages before and after, Cargo.toml untouched, and
`cargo metadata --locked` accepts the result without rewriting it.

Verified: cargo check --locked and cargo test --locked green (111
passed), cargo-audit 0.22.2 exits 0 with 12 allowed warnings, and the
same binary still exits 1 on the previous lockfile — so the green is a
real change of state, not a differently configured tool. Both checksums
match the sha256 of the downloaded .crate archives, and compiling rustls
0.23.45 and rustls-webpki 0.103.15 proves those archives were the ones
actually used.

Resolves #348

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:09:39 -04:00
le king fu
97ee3c354b state: sync after /analyze 270 (issues #346, #347, la-compagnie-maximus#153)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159y7AeM6djQLDN2vZ8soPa
2026-09-13 20:20:26 -04:00
le king fu
a9188b22a0 state: sync after v0.15.0 update-cycle QA (release step 10)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159y7AeM6djQLDN2vZ8soPa
2026-09-13 14:01:25 -04:00
le king fu
f210bf3647 docs(qa): the update-cycle trace lives in the release body, not a comment
Forgejo has no comments on releases. Point the trace step at a section
appended to the release notes and record the PATCH call, whose body
replaces the notes wholesale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159y7AeM6djQLDN2vZ8soPa
2026-09-13 14:01:25 -04:00
le king fu
825c602ca9 state: sync after v0.15.0 release
Record the release (run 402, 7 assets + latest.json verified), the two
Premium tester keys issued before the tag, the VPS Tailscale node-key
expiry recovery, and the pending step 10 (real update cycle) as the
active follow-up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013CNa9VVF5C3y47MX1SSJ1S
2026-09-12 15:17:10 -04:00
le king fu
920dedde10 chore: release v0.15.0
All checks were successful
Release / build-and-release (push) Successful in 22m32s
Bump 0.14.0 -> 0.15.0 in the five manifests, open the 0.15.0 section in
both changelogs (17 entries: edition-based feature gating, the CSV import
rework with migration v17, three dependency advisory batches), and fix the
stale "Version actuelle" line in CLAUDE.md (was 0.6.3).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013CNa9VVF5C3y47MX1SSJ1S
2026-09-12 14:33:37 -04:00
le king fu
14a4a81d21 state: correct the #345 framing after analysis
The line said the constraint regresses on "the next cargo update". Measured:
`cargo update -p anyhow` moves nothing, `-p hyper` moves all seven. The
trigger is reaching the networking/TLS subtree, which is exactly where the
advisories land. Also records what the analysis overturned: #310 did notice
the drift and judged it harmless on Linux, where it is — the cost lands on
the Windows build (3 windows-sys crates compiled vs 5), and there is no
correctness risk since all seven declare ranges covering both states.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhPofpLQTZUbkVYR3i2e2D
2026-09-04 16:44:34 -04:00
le king fu
65f6c778a4 state: sync after #343/#344 (daily audit was red for 17 days, now green)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhPofpLQTZUbkVYR3i2e2D
2026-09-04 16:11:35 -04:00
le king fu
5154c5276b fix(deps): update h2 to 0.4.19 to clear RUSTSEC-2026-0258
All checks were successful
PR Check — Rust / rust (pull_request) Successful in 10m1s
The daily audit has failed every morning since 2026-08-19 (runs 375 to
391, 17 consecutive reds). Nothing was pushed to main in that window:
RUSTSEC-2026-0258 was published on 08-17 and reached the advisory-db two
days later. Empty HTTP/2 DATA frames are queued without limit, so an
undrained stream grows memory without bound and can overflow into a
panic. Low severity, denial of service, patched in 0.4.16.

h2 is reachable on both shipped targets, checked per triple rather than
with --target all: through tauri-plugin-updater -> reqwest 0.13.2 for
the automatic updater, and through the app's own reqwest 0.12.28 for the
licence, auth, price and feedback calls. A published fix plus a
reachable crate is rule 1 of .cargo/audit.toml, so this gets fixed
rather than suppressed; the suppression list and the check-rust guard
are both untouched.

Only the h2 stanza moves. Running `cargo update -p h2` also re-resolves
seven windows-sys pointers from 0.61.2 down to 0.48/0.52/0.59, which is
what #321 deliberately reverted three weeks ago after proving 0.61.2 is
the state of the v0.14.0 tag and therefore the only windows-sys
configuration that has actually compiled under Windows. Letting the
re-resolution stand would regress that decision silently, so the bump is
applied to the lockfile as a two-line change instead. `cargo metadata
--locked` accepts the result without rewriting it.

Verified: cargo check and cargo test --all-targets green (111 passed),
cargo-audit 0.22.2 exits 0 with the same 22 allowed warnings as before,
and the same binary still exits 1 on the previous lockfile — so the
green is a real change of state, not a differently configured tool.

Resolves #343

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhPofpLQTZUbkVYR3i2e2D
2026-09-04 15:44:29 -04:00