Bank Sync cleanup (#1710)

* feat(settings/providers): surface connection status in section headers

Lifts the per-panel status indicator up to each collapsed accordion
header so admins can see at a glance which providers are connected
without expanding every section. Connected providers sort first.

- Add optional status: and meta: locals to settings/_section partial;
  pill hides via group-open:hidden when the section is expanded
- New settings/providers/_status_pill partial (ok/warn/err/off states)
- Add SettingsHelper#provider_summary to centralise the connected-vs-not
  logic already scattered across panel partials
- Refactor show.html.erb to pass status to every section and sort
  family_panels by connection state
- Add settings.providers.status.* i18n keys
- Add system tests asserting pill renders and sort order

https://claude.ai/code/session_01KW2HCN9rP1fiyQuw7Cju9D

* feat(settings/providers): group providers into Connected and Available

Partition the provider list in the controller into @connected_providers
and @available_providers based on provider_summary status, and render
each group under its own heading with a count. Auto-open the section
when only one provider is connected. Adds an empty-state line when
nothing is connected yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(settings/providers): health strip, action-needed group, and sync error surfacing

- Extend provider_summary to return :err/:warn with meta text by checking
  latest sync per item (window function, same pattern as ProviderConnectionStatus)
  and Enable Banking session expiry within 7 days
- Partition provider entries into three groups: Connected (:ok), Action needed
  (:warn/:err, auto-opened), Available (:off)
- Add Settings::HealthSummary ViewComponent — four-tile grid showing Connected,
  Action needed, Errors, and Accounts synced counts
- Render health strip directly under page description; omit Action needed heading
  when group is empty
- Add i18n keys for tile labels, group heading, and all meta strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(settings/providers): card grid for available providers with connect drawer

- Add Provider::Metadata registry with static display data (region, kind,
  tier, maturity, logo) for all 11 providers
- Add Settings::ProviderCard ViewComponent rendering logo square, name,
  Beta/Alpha pill, meta line (region · type · tier), tagline, and Connect link
- Add connect_form action + route (GET /settings/providers/:key/connect_form)
  that opens the existing panel partial or config form in a DS::Dialog drawer
- Replace the Available accordion loop with a 2-column responsive card grid;
  empty state when all providers are connected
- Fix layout override: use turbo_rails/frame layout for frame requests so the
  drawer response is not wrapped in the full settings layout (was causing
  Turbo to pick the empty outer drawer frame instead of the filled one)
- Add SyncAllProvidersJob and last_sync_all_attempted_at migration (sync-all
  throttle support)
- Unify Connected + Action needed into a single "Your connections" section;
  items with warn/err status auto-open
- Fix Enable Banking grouping: items with expired sessions were returning
  :off (Available) instead of :warn (Your connections); gate now checks
  any? instead of any?(&:session_valid?)
- Add reconsent_required locale key for fully-expired EB sessions
- Surface Beta/Alpha maturity pills on connected provider accordion rows
  via new badge: param on settings_section helper
- Add i18n taglines for all 11 providers; add connect and empty_available keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(settings): retire /settings/bank_sync; merge into providers page

- Delete Settings::BankSyncController and its views (the providers page is
  now a strict superset of what bank_sync offered)
- Add permanent 301 redirect: GET /settings/bank_sync → /settings/providers
- Collapse nav to a single "Bank Sync" entry pointing at /settings/providers;
  remove the duplicate admin-only "Providers" entry from the Advanced section
- Remove "Providers" from SETTINGS_ORDER; point "Bank Sync" at
  settings_providers_path for next/prev navigation
- Rename page title to "Bank Sync"; replace admin-credential lede with
  user-facing copy ("Connect external accounts…")
- Update breadcrumb: Home → Bank sync
- Add controller test asserting 301 status and Location header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Migrations are 7.2 here

* Minimize schema noise

* Schema duplication

* Small copy edits

* Fix tests

* Address provider settings review feedback

* refactor(settings/providers): finish design-review cleanup pass

Picks up the remaining items from Claude Design's review of #1710
that the previous review-feedback commit didn't cover.

DS / casing
- Sentence-case the page title ("Bank Sync" -> "Bank sync") and
  align the nav label.
- Drop the card hover-lift (shadow-border-sm) in favour of
  bg-container-hover; per the DS, card hover is colour-only.
- Whole-tile click target on each provider card — the inner
  "Connect ->" link was a hit-target inversion.
- Set Sync all to whitespace-nowrap so the label stops wrapping at
  narrow viewport widths.

UX simplifications
- Drop the four health-summary tiles (per-row warn/err pills already
  surface the signal at the scale this app sees). Removes
  Settings::HealthSummary, the @health_counts controller block, and
  the now-unused health.* locale keys.
- Hide "Your connections" heading + empty-state line when no
  providers are connected — the lede already invites a connect.
- Drop the redundant "Free" tier from per-card meta lines (printed
  10x for one fact); "Paid" still surfaces on Plaid.

Tests updated to drop the obsolete tiles assertion and switch the
provider-card click selector to look up the (now whole-card) anchor
by provider name.

* feat(settings/providers): replace Add another provider CTA with a search + kind filter

Per the design review, the "Add another provider · Browse providers"
card was a redirect to content one scroll-tick away. A search input
plus kind chips lets users self-segment the catalog and is the right
tool once it grows beyond the four to twelve providers we ship today.

- New providers_filter Stimulus controller — case-insensitive free
  text search across name/region/kind, plus a chip group with
  All / Banks / Crypto / Investment that toggle visibility via
  Tailwind's `hidden` class.
- _search_filters partial: search box (count-pluralized placeholder)
  + chip group, ARIA-labelled and aria-pressed for the chips.
- ProviderCard exposes filter_data (target + name/region/kind data
  attrs) so the controller can match without re-rendering.
- Lunchflow's `kind` was "Lunch" — switched to "Bank" so it falls
  under the Banks chip alongside its actual offering (it aggregates
  banks).
- Drops the add_provider_cta partial and its locale entries; adds
  search_filters.* and an empty_filter message.

* Private method fix

* refactor(settings/providers): drawer cleanup, header lock-up, trust statement

Per the design review's §07.

- Drop the trailing "Configured / Not configured" footer status from
  every provider panel (binance, coinbase, coinstats, indexa_capital,
  lunchflow, mercury, simplefin, snaptrade, sophtron, provider_form).
  The parent details section's status pill already carries that
  signal; the footer was redundant — and the copy/styling was
  inconsistent across panels (free-text vs. dot pill, "configured"
  vs. "not connected").
- Connect drawer gets a header lock-up: small logo chip + provider
  name + maturity badge, mirroring the available-card layout.
  Implemented as _drawer_header partial; connect_form passes
  custom_header: true to DS::Dialog so we own the row.
- Drawer footer trust statement: "Read-only — Sure can never move
  money. Stored encrypted." A single-line reassurance covering all
  panels.
- Sentence-case the hardcoded primary buttons that were Title Case:
  "Save Configuration" -> "Save and connect"
  "Update Configuration" -> "Update connection"
  "Connect Bank" -> "Connect bank"
  Affects simplefin, lunchflow, enable_banking, provider_form. The
  i18n'd panels (binance, coinbase, coinstats, indexa_capital,
  mercury, snaptrade, sophtron) keep their existing keys.

* chore(locales): drop unused provider-panel status strings

Footer "Configured / Not configured" status was removed from each
provider panel partial in the prior drawer-cleanup pass; the matching
i18n keys are no longer referenced. Removing them across every
locale to keep the catalogue clean.

Dropped (15 keys × varying locale coverage, 36 line removals across
24 files):

- coinstats_items.new.{status_configured_html, status_not_configured}
- indexa_capital_items.panel.{status_configured_html, status_not_configured}
- mercury_items.provider_panel.{configured_html, not_configured, accounts_link}
- sophtron_items.sophtron_panel.status.{configured_html, not_configured}
  (parent `status:` removed where it became empty)
- providers.snaptrade.{status_needs_registration, status_not_configured}
  (status_connected stays — still used by the lazy-load summary)
- settings.providers.{binance_panel, coinbase_panel}.{status_connected, status_not_connected}

* feat(settings/providers): connected-state polish per design §05 + Linked institutions rename

Building the next phase of the design review. Pulls forward the
slim health strip, denser connection rows, and "Linked institutions"
heading rename — the small Phase A lift the designer flagged in
§08 of the doc.

- New _health_strip partial: single-line at-a-glance pulse —
  connected count + needs-attention count + accounts syncing +
  last-synced timestamp. Renders only when at least one provider
  is linked or needs action.
- New _connection_row partial replaces the generic settings_section
  call for providers. Tighter rows: text-sm title (was text-lg),
  px-4 py-3.5 padding, single-line summary (chevron + name +
  maturity badge + meta + status pill + sync action). Warn/error
  rows get a coloured outline (border-warning/25 or
  border-destructive/25) so the at-risk row stands out without
  shouting.
- "Sync all" button restyled to match the design's secondary
  button: text-primary, alpha-black-100 border, rounded-[10px],
  padding 7px 12px (was the broader px-3 py-1.5 ghost).
- "Your connections" → "Linked institutions" heading, lifted from
  the designer's Phase-C reconciliation note. Primes users for the
  Option-C institution-search wizard six months early; existing
  i18n key stays as `groups.your_connections` for now to keep the
  rename to a single value flip.
- Controller computes the new @health hash (connected,
  needs_attention, accounts_syncing, last_synced_at) feeding the
  strip; brings back the single accounts query that was removed
  with the four-tile component.

System test updated for the new heading copy.

* fix(settings/providers): align connected state with the final design mock

Tightening the §05 polish to match the user-confirmed final design.

- Revert "Linked institutions" → "Your connections". The §08
  designer note about the Phase-A heading rename didn't carry
  forward to the final mock; keep the original wording.
- Drop the warn/err auto-open on connection rows. The design shows
  Enable Banking collapsed with a warn-outline and a status pill —
  no auto-expanded form. Single-connection auto-open kept (handy
  when the page is otherwise empty).
- Hide the "accounts syncing" segment in the health strip when the
  count is 0 — the design mock assumes a populated number; an
  always-visible "0 accounts syncing" reads as a placeholder.
- Strip the leading "about " from `time_ago_in_words` everywhere
  the result is shown to the user (health strip "Last synced %{time}
  ago" plus per-row "Synced %{time} ago" meta). Matches the design's
  shorter copy.

* refactor(settings/providers): tighten paddings, dedupe maturity badge, semantic + a11y fixes

Pixel-level alignment to the design's §05 mock + cleanup from a DS
audit pass.

Paddings, margins, font sizes
- Health strip: my-4 → mt-4 mb-5 to match the design's 16px / 20px
  vertical breathing room.
- Search filters bar: gap-2 → gap-2.5; mt-2 → mt-5 mb-3 (was missing
  the 12px bottom margin entirely).
- Search box: rounded-lg → rounded-[10px]; px-3 py-2 → px-[14px]
  py-[9px]. Search icon downsized w-4 → w-3.5 to match.
- Chip group: p-1 → p-[3px]; rounded-lg → rounded-[10px].
- Chip: py-1 → py-[5px]; rounded-md → rounded-lg.
- Group heading: mt-2 → mt-[18px]; mb-1 → mb-1.5.
- Status pill: text-xs → text-[11px].
- Provider card: gap-3 → gap-2.5 (outer + top); name gets explicit
  text-sm; tagline + foot 14px → 13px; arrow icon w-4 → w-3.5.
- Sync icon button: p-1 → fixed w-7 h-7 (28×28) so the row hit
  target matches the design's column width.
- Connect drawer header logo glyph: text-[10px] → text-xs (matches
  the available card's logo-glyph treatment).

Component / partial cleanup (DS audit follow-ups)
- New _maturity_badge partial replaces the inline span that was
  duplicated in 3 places (_connection_row, _drawer_header,
  provider_card.html.erb).
- Settings::ProviderCard.maturity_label class method centralizes the
  MATURITY_LABELS lookup; callers no longer reach into the constant.
- _connection_row title: <h2> → <h3> (the row sits inside the
  "Your connections" h2 group heading; nested h2s flattened the
  outline).
- show.html.erb encryption error: <h3> → <h2> for the same reason.

Locale
- Drop orphaned keys: settings.providers.groups.connected and
  groups.needs_attention (no view code uses them) plus the leftover
  show.coinbase_title block.
- Health strip "needs reconsent" → "needs attention" so the strip
  copy lines up with the per-row status pill ("Action needed") and
  the original group heading wording.

A11y
- focus-visible:ring-2 on chip buttons, provider-card link, and
  focus-within:ring-2 on the search input wrapper. Keyboard users
  now get a visible focus state.
- Search input: explicit autocomplete="off" (erb_lint hint).

* fix(settings/providers): icons + search input height

- Icons were rendering at 20px because the application_helper's `icon`
  default size (`md` = w-5 h-5) was beating the inline class override
  in compiled CSS source order. Pass `size: "sm"` and use the project's
  `!w-3.5 !h-3.5` important-prefix pattern (precedent: dashboard.html.erb)
  so chevron, refresh-cw, search, check, circle-alert, and arrow-right
  all render at the design's 14px.
- Search input was 54px tall because @tailwindcss/forms applies
  `padding: 8px 12px` to bare `<input type="search">`. Override with
  `!p-0 focus:ring-0 focus:shadow-none` so the wrapping div's padding
  alone defines the box (38px total — matches the design).

* refactor(settings/providers): align Sync all + search input with DS, address review feedback

- Sync all: replace the hand-rolled `button_to` with `DS::Link.new(variant: "outline", method: :post)` — same component as the
  "Identify Patterns" button on the recurring-transactions page.
- Search input: switch to the icon-overlay pattern used by the
  Manage-currencies and transaction filter rows
  (relative wrapper + absolutely positioned search icon +
  bordered input with `focus:ring-gray-500`). Brings the keyboard
  focus state in line with the rest of the app's filterable lists.
- SnapTrade panel: restore the "needs registration" status row that
  the drawer-cleanup pass dropped along with the redundant
  Configured/Not configured footer. The unregistered case is
  meaningful state, not redundant chrome.
- Move the slim health-strip computation out of the controller and
  into `SettingsHelper#provider_health_strip` (Convention 2: skinny
  controllers).
- Extract `concise_time_ago` helper so the "drop leading 'about '"
  trick stops being duplicated 3x.
- `Settings::ProviderCard#maturity_label` (instance) now delegates
  to `.maturity_label` (class) instead of duplicating the lookup.
- Drop unused `warn_or_err` local in `_connection_row`.
- Replace the `data-controller` string-injection + html_safe in
  `_connection_row` with `tag.details(data: ...)`; safer and more
  idiomatic.
- Add a system test for the empty-filter message wiring.

* fix(settings/providers): drawer trust statement uses border-tertiary

`border-secondary/10` was reaching for the text-foreground token at
10% opacity for a divider. The project ships a dedicated divider
token (`border-tertiary`, ~8% black) used by DS::Menu, the holdings
page, and admin/sso forms. Switching to it makes the trust-statement
HR match every other thin divider in Sure and stops misusing the
text token as a border.

* refactor(settings/providers): swap arbitrary Tailwind values for scale tokens

Per the user's directive — DS-compliance over pixel-perfect alignment
with the design mock. Walked the design audit and applied every swap
that lands within ±2px of the original.

Swaps:
- _health_strip: gap-[18px] → gap-5 (+2), px-[14px] → px-3.5 (=),
  text-[13px] → text-sm (+1).
- _search_filters: chip group p-[3px] → p-1, rounded-[10px] →
  rounded-xl (concentric with rounded-lg inner pills), chip py-[5px]
  → py-1.
- _status_pill: text-[11px] → text-xs.
- _group_heading: mt-[18px] → mt-5.
- _maturity_badge: text-[10px] → text-xs.
- provider_card: tagline + foot text-[13px] → text-sm.

Kept arbitrary: `min-w-[200px]` in _search_filters — nearest scale
tokens are min-w-48 (192px) and min-w-52 (208px); both are noticeable
layout shifts for a one-off responsive guard. Worth keeping the
arbitrary here.

Net: 9 of 10 arbitrary values gone. Visual delta: max +2px on a
single value. Design mock and DS scale now agree.

* revert(settings/providers): drop the slim health strip

Per-row status pills already carry the at-a-glance signal (connected
/ action needed) at the scale this app sees (1–4 connections per
family). The strip was redundant chrome for almost every user; only
worth bringing back if the catalog grows to a point where the row
list itself stops fitting on a single screen.

- Delete _health_strip.html.erb partial.
- Drop @health controller assignment + provider_health_strip helper.
- Drop unused settings.providers.health_strip.* locale keys.
- concise_time_ago helper stays — still used by per-row meta text.

* refactor(settings/providers): align with DS conventions

Two consistency wins from the screenshot/DS audit pass.

Sync icon button now renders DS::Button (variant: icon, size: sm)
instead of a hand-rolled `button_to`. Same component used by other
icon-only actions across the app (settings/profiles, layouts/imports).
Visual delta: 28×28 → 32×32 (DS sm size). Accept the +4px for
consistency. `event.stopPropagation()` still wired via the form opt
so the row's <details> doesn't toggle when the user clicks the
button.

Group heading now follows the established Sure section-label style
(`text-xs font-medium text-secondary uppercase`) used by
`_settings_nav` and the imports/categories surfaces. The previous
sentence-case `text-sm text-primary` was a one-off that didn't
match the rest of the app. Locale strings stay sentence-case;
uppercase comes from CSS `text-transform`. Tests updated to
case-insensitively match the rendered heading text.

* fix(provider/metadata): add plaid_eu entry

`plaid_eu` is registered as a separate Provider::ConfigurationRegistry
entry but had no Provider::Metadata row, so its card in the
Available grid fell through to the gray-500 default and rendered
empty (no region, kind, tier, or tagline). The title also came out
as "Plaid Eu" because `titleize` doesn't know "EU" is an initialism.

- Add a `plaid_eu` row to Provider::Metadata::REGISTRY with the same
  shape as `plaid` (US → EU, otherwise identical).
- Introduce an optional `name:` field in metadata; controller falls
  back to it before titleizing the provider key. Lets `plaid_eu`
  render as "Plaid EU".
- Add the missing `settings.providers.taglines.plaid_eu` translation.

* fix(settings/providers): center-align Sync all next to the lede

`items-start` made the button hug the first line when the lede wrapped;
on a single line the button sat at the top of the text bounding box
which read slightly off. Center matches the dominant convention
across the rest of settings (api_keys, securities, hostings, _section,
_settings_nav_link_large).

* fix(settings/providers): drop colour palette + filter polish + drawer warnings

Round of design-feedback fixes.

Provider chips
- Drop the per-provider raw Tailwind palette (bg-blue-600 etc.) from
  Provider::Metadata. All cards + drawer logo lock-up now use
  bg-surface-inset + text-primary, matching the design's §04 "drop
  colour entirely" recommendation. Solves the long-standing §01
  BLOCKER without externalising brand assets. Re-introducing logos
  later just means an optional logo_svg: field on metadata.
- ProviderCard component drops the `logo_bg:` parameter; the chip
  is now styled in the template.

Filter / search
- "Available · N" count and the empty-filter state now update
  client-side as the chip filter and free-text search narrow the
  grid (new `count` Stimulus target + dedicated update path).
- Empty-filter state now offers a Clear filters button that resets
  both the search input and the active chip in one click.
- Search placeholder drops the drifting "Search 9 providers" count
  for plain "Search providers" — the section heading carries the
  number.
- Chip labels normalised to plural where natural: "Banks · Crypto ·
  Investments" (Crypto stays as the mass noun).

Drawer copy / treatment
- "IP Whitelisting Required" → "IP whitelisting required" (DS
  sentence-case).
- Binance "do NOT enable withdrawal permissions" lifted out of
  inline red-text into a proper bg-warning-50 border-warning-200
  alert block with an alert-triangle icon. Matches the api_keys /
  hosting alert pattern.
- SnapTrade free-tier inline alert-triangle now uses `size: "sm"`
  so the icon stops rendering at 20px next to 14px body text.

Spacing
- Group-heading margin top bumped 5 → 6 (20→24px) so the eyebrow
  has more breathing room above the search bar.

* refactor(settings/providers): drawer alerts use DS::Alert; drop card-in-card

Two consistency fixes from a design-review pass.

DS::Alert adoption
- Replaces 9 hand-rolled error blocks across the provider panels
  (`bg-destructive/10 text-destructive ... line-clamp-3`) with
  `DS::Alert(variant: :error)` — the project's existing primitive.
- Replaces the just-shipped Binance no-withdraw warning block with
  `DS::Alert(variant: :warning)` instead of a hand-rolled
  `bg-warning-50 border-warning-200` card.
- Replaces the SnapTrade free-tier inline icon-prefixed warning
  paragraph with `DS::Alert(variant: :warning)` — proper alert
  treatment for an actual warning, not body copy.
- Replaces the Enable Banking "Configuration locked" inline
  `bg-warning/10` two-paragraph block with `DS::Alert(variant: :warning)`
  using `safe_join` for the title + body.
- Replaces the encryption-error block at the top of show.html.erb
  with `DS::Alert(variant: :error)`, again via `safe_join`.

Mercury card-within-card
- The "Add another Mercury connection" form was wrapped in a
  `<details>` `bg-container shadow-border-xs rounded-xl` card. In
  the Connect drawer (always 0 existing connections), that wrapping
  card-inside-the-drawer-card has no value — the form is the only
  thing on the surface. Drop the wrapper when no connections exist;
  keep the heading + form inline. When 1+ connections exist (the
  section page) the heading hints "+ Add another connection"
  without the disclosure indirection.

Trade-off: the error-alert blocks lose their `line-clamp-3` /
`title=` truncation. Acceptable for now — DS::Alert can grow a
truncate option as a follow-up if needed.

Open follow-up: DS::Alert itself uses raw Tailwind palette
(`bg-yellow-50` etc.) instead of semantic tokens, and only accepts
a single string `message:`. A separate issue tracks this.

* fix(settings/providers): hoist warning alerts to top of drawer

DS::Alert convention across the rest of the app: alerts sit at the
top of the form / page / section, not floating between content
blocks. The Binance no-withdraw warning and SnapTrade free-tier
warning were rendering between the setup-instructions list and the
form fields — visually wonky.

Move both to the top of their respective panels so the warning is
the first thing the user sees when the connect drawer opens.

Existing precedents this aligns with:
- accounts/_form.html.erb (error alert above form)
- valuations/new.html.erb (error alert above form)
- other_assets/new.html.erb (info alert above form)
- holdings/show.html.erb (warn alerts above content)

* fix(DS::Alert): align icon to cap-height of first text line

`items-start` on the container made the icon's top edge flush with
the text's top edge, leaving the icon's optical center sitting below
the text's first-line center. The hand-rolled alerts elsewhere in
the codebase (api_keys/new, hostings/_sync_settings, holdings/show)
all add `mt-0.5` to the icon for the same reason — fold that into
the primitive so every caller gets the cap-height alignment.

* copy(settings/providers): tighten alert messaging per voice review

Copy expert pass on the new provider drawer alerts. House style:
sentence case for titles, lead with the action, drop "Warning:" /
"Please" filler (the alert variant icon already signals tone),
prefer one short sentence + optional title-paragraph for emphasis.

- Binance no-withdraw warning: was a single line "Warning: do NOT
  enable withdrawal permissions" — alarmist without context. Now
  splits into "Read-only key only" (title) + "Don't enable
  withdrawal permissions when creating your Binance API key — Sure
  only needs read access." (body).
- SnapTrade free-tier note: "Free tier includes 5 brokerage
  connections. Additional connections require a paid SnapTrade
  plan." → "SnapTrade's free tier covers 5 brokerage connections.
  Upgrade on SnapTrade for more."
- SnapTrade connection-limit-info inside the brokerage list: cut
  entirely. The drawer already shows the cap; restating it in the
  list was noise.
- SnapTrade needs-registration: "Credentials saved — finish
  registration to connect a brokerage." → "Credentials saved.
  Finish setup to connect a brokerage." ("registration" was
  ambiguous — register where, with whom?)
- Enable Banking "Configuration locked" body: "Credentials cannot
  be changed while you have active bank connections. Remove all
  connections first to update credentials." → "Disconnect all
  linked banks before changing these credentials." Same meaning,
  half the words.
- Encryption-error block: title-cased "Encryption Configuration
  Required" → "Encryption keys missing"; body strips "Please
  ensure" filler and the parenthetical credential dump, leaving
  the three credential names inline as a clean list. Self-hosters
  still get exactly the names they need to set.

* feat(settings/providers): SetupSteps partial for connect-drawer instructions

Per the design's drawer-cleanup follow-up. Replaces the per-panel
"Setup instructions:" + ordered list + "Field descriptions:" block
with a shared boxed-step component.

The new partial — `_setup_steps.html.erb` — takes a `steps:` array
of strings (or html_safe strings for inline links / code) plus an
optional `help:` hash for a docs link below the steps. The eyebrow
label is "Setup" (uppercase, tracking-wider) matching Sure's other
section labels.

Applied across all eleven provider panels:
- _provider_form (Plaid + Plaid EU): field descriptions move to
  per-field helper text below the input.
- _binance, _coinbase, _coinstats, _indexa_capital,
  _lunchflow, _mercury, _simplefin, _snaptrade, _sophtron,
  _enable_banking: ordered list + duplicate "Field descriptions"
  block both replaced by the partial.
- Some panels' inline copy tightened in the same pass (Lunch Flow,
  SimpleFIN, Enable Banking) — the design copy is shorter than the
  current legacy strings; a copy-pass through every panel can
  follow as a separate cleanup.

Token notes: uses scale tokens (`rounded-xl`, `text-xs`/`text-sm`,
`tracking-wider`) instead of the design mock's exact arbitrary
values, per the consistency-over-design-specs directive on this
branch.

* fix(settings/providers): tighten panel spacing + relocate per-panel notes

Read-flow audit on each connect drawer. The uniform `space-y-4`
treated every block (alert, steps, info card, fields, button) the
same — visually they were five sibling boxes with no grouping. The
fix is per panel; some notes belong as helper text on a specific
field, others as a tightly-grouped pre-fill primer.

Per panel:

- Binance: IP-whitelisting card now matches the setup_steps box
  (`bg-surface-inset rounded-xl`) and is wrapped with setup_steps
  in an inner `space-y-2` so they read as a single pre-fill primer
  cluster. Same eyebrow treatment ("IP whitelisting required") so
  the two boxes look like sister panels, not unrelated chrome.

- SnapTrade: drop the description paragraph above setup_steps. The
  available-providers card grid already markets SnapTrade
  ("Connect brokerage accounts via the SnapTrade aggregation
  network."); repeating in the drawer was duplication.

- Mercury: move the sandbox-API note out of its standalone <p>
  below setup_steps and into per-field helper text under the
  base_url field — the user only cares about the sandbox URL when
  they're filling that field. Applied to both the per-item edit
  form and the add-new form.

- _setup_steps partial: drop the now-pointless `mb-2` (outer
  `space-y-4` already controls the gap; bottom-margin was dead
  CSS thanks to margin-collapse rules with the next sibling's
  margin-top).

* fix(settings/providers): plaid + indexa drawers join the SetupSteps look

Two unifying fixes after the panel-by-panel screenshots showed
mixed treatments.

Plaid + Plaid EU
- The registry-driven panel (_provider_form) was still rendering
  each adapter's markdown `description` block as plain prose
  ("Setup instructions: 1. Visit the Plaid Dashboard ..."). Other
  panels switched to the SetupSteps box; Plaid was the odd one out.
- Drop the markdown `description` block from both plaid_adapter
  and plaid_eu_adapter. Render setup_steps in _provider_form for
  these two provider keys via inline ERB (link helper handles the
  Plaid Dashboard link cleanly; the regional differences fold to
  the same dashboard URL with a different account scope).
- Other registry-based providers fall through to the previous
  markdown description path — no behavior change for them.

Indexa Capital
- The API token field was wrapped in a `bg-surface border` "card"
  that duplicated the field label inside as a heading and put the
  description above the input. Same pattern the user flagged as
  the "card within input" anti-shape.
- Drop the wrapper. The styled-form input renders its own label;
  description moves to per-field helper text below the input,
  matching the pattern used by Plaid (provider_form) and Mercury.

* fix(settings/providers): surface configured plaid_eu + dedup show context

provider_summary had no plaid_eu branch — configured plaid_eu was
falling through to status :off and rendering in Available even with
credentials set. Collapse plaid + plaid_eu into a single registry
check.

Drawer title for non-panel configurations was provider_key.titleize,
which produced "Plaid Eu" while the available card grid used
metadata[:name] = "Plaid EU". Read from metadata first.

While here:
- compute_provider_sync_health no longer relies on
  instance_variable_get; pass family_panel_items explicitly so the
  hash-key/ivar-name coupling is gone.
- drop unused .includes(:syncs, :mercury_accounts) and
  .includes(:snaptrade_accounts) from prepare_show_context. The show
  view only consults summary[:status]; the eager-loads were carried
  over from connect_form (which has its own load_provider_items).

* i18n(settings/providers): localize plaid setup steps + drop dead defaults

The plaid + plaid_eu setup steps in _provider_form.html.erb were
hardcoded English strings. Move them to settings.providers.plaid_panel
(shared) + plaid_eu_panel (EU-specific step 1) so they can be
translated like every other panel.

_setup_steps.html.erb was passing default: "Setup" / "Need help?" to
t(), masking missing translations in non-EN locales. Both keys exist
in en.yml — drop the defaults so missing translations actually
surface.

* test(settings/providers): cover plaid_eu, clear filters, warn outline

Three system test additions:

- Configured plaid_eu surfaces in Your connections (regression guard
  for the helper fix; previously fell through to Available).
- Clear filters button resets input + chip state and brings cards
  back into view.
- :warn-state connection row carries the border-warning/25 outline
  that distinguishes it from an :ok row.

* copy(settings/providers): drop em dashes, naturalize phrasing

Sweep through every string this branch added and replace em-dash
splices with full sentences or simple connectives.

en.yml:
- drawer_trust_statement now reads "Read-only access. Sure can never
  move money, and your credentials are stored encrypted." instead
  of em-dash splicing.
- sync_all_recently / recently_synced split into two sentences.
- binance_panel.no_withdraw_body, plaid_panel.step_1_html / step_2,
  plaid_eu_panel.step_1_html same treatment.

Hardcoded panel steps (enable_banking, lunchflow, simplefin) become
"Go to <link> and …" or "Go to <link> for …" instead of the
"<link> — get …" splice. Same setup_steps comment cleaned up.

* fix(settings/providers): address CodeRabbit pass on PR #1717

Fixed:
- Localize the setup steps in _enable_banking_panel,
  _lunchflow_panel, and _simplefin_panel. The em-dash sweep had
  rewritten these into hardcoded English; they now route through
  settings.providers.{enable_banking,lunchflow,simplefin}_panel
  step_1_html / step_2 / step_3 keys, mirroring the plaid_panel
  treatment.
- connect_form: silent redirect when provider_key is unknown now
  carries an alert (settings.providers.not_found) so misrouted
  links don't drop users on the page with no feedback.
- sync action: redirect notice now reflects whether anything was
  actually scheduled — adds settings.providers.sync_provider_no_items
  for the "all items already syncing or none exist" path.
- Family::Syncer test: count plaid_items via the .syncable scope to
  match what Family::Syncer actually schedules (already done for
  binance_items in the same test).

Skipped, with reasons:
- focus:ring-gray-500/-gray-900 in coinstats / coinbase / simplefin /
  search_filters: tracked under issue #1715 as part of the raw-palette
  → DS-token sweep across the whole codebase.
- Coinbase #0052FF brand-color wrapper: tracked under PR #1710's
  follow-up tracking comment as the deferred Provider::Metadata
  colour-palette decision (designer §01).
- Sophtron submit-button extraction into DS::Button: same
  deferred sweep — every panel hand-rolls this class string;
  one-off extraction would just churn.
- Redundant .html_safe on _html keys in coinstats: tracked in #1715.
- _provider_form.html.erb env hint, "Optional" placeholder, "Save and
  connect" submit: pre-existing strings not added on this branch.
- Renaming sync_health_for's :stale to :data_stale: pre-existing
  shape, refactor scope.
- Plaid_eu using plaid_panel.step_2/step_3 keys: deliberate. Same
  English copy across both providers; duplicating keys would just
  give translators twice the work for identical strings.
- _enable_banking_panel / _lunchflow_panel / _simplefin_panel
  alert + submit + button labels: pre-existing hardcoded strings
  from before this branch. Setup steps were the strings actually
  touched in the em-dash sweep, so those got localized; the rest
  belong in a broader panel-i18n pass.

Verified:
- bundle exec erb_lint on the three panels: clean.
- bin/rubocop on controller + test: clean.
- bin/rails test test/models/family/syncer_test.rb
  test/controllers/settings/providers_controller_test.rb:
  23 runs, 85 assertions, 0 failures.
- DISABLE_PARALLELIZATION=true bin/rails test
  test/system/settings/providers_test.rb:
  15 runs, 38 assertions, 0 failures.

* fix(db): rename migration to clear collision with main's 20260508120000

Main's PR #1705 (Sophtron manual sync) shipped a migration with
the same 20260508120000 timestamp as our
add_last_sync_all_attempted_at_to_families migration. The merge
that brought main into this branch left both files at the same
prefix, which trips Rails' "Duplicate migration" guard at
db:schema:load time and broke CI.

Renaming our migration to 20260510120000 keeps the column it adds
intact (already in db/schema.rb) and bumps the schema version to
match. No DB-level change.

* fix(settings/providers): card + strip a11y polish

- Bring back the slim health strip; gate behind 10+ accounts
  (HEALTH_STRIP_MIN_ACCOUNTS) so it stays out of the way for
  small libraries where per-row pills already carry the signal.
- Status pill: drop the bg-{c}/10 text-{c} pattern (failed AA
  on warn / err); switch to bg-surface-inset text-primary with
  the dot still carrying semantic colour. Passes AA in both
  themes; the dot is the only colourful affordance.
- Maturity badge: bg-alpha-black-50 was invisible against the
  hovered card bg in light mode and against bg-container in
  dark mode. Move to bg-surface-inset + border-tertiary so it
  stays delineated through hover and dark theme.
- Provider card: keep the bg shift on hover (now bg-surface-inset
  for a perceptible delta), focus ring promoted alpha-black-100
  -> alpha-black-300 (visible to keyboard users), meta line
  text-subdued -> text-secondary (text-subdued failed AA at
  2.86:1 against bg-container).
- Restore the per-provider logo palette dropped in 6abceb07.
  Yellow-on-white was the BLOCKER then; bumped Binance to
  yellow-600 and CoinStats to pink-600 (distinct from Binance
  and AA-safe with white text).
- Health strip dividers: bg-alpha-black-100 was invisible in
  dark mode. Switch to border-l border-secondary so the DS
  variant flips correctly.

* fix(settings/providers): keep row height on open

The right-side meta + status pill + sync button group is hidden
via group-open:hidden, but the sync button (DS::Button size sm,
h-8) is what dictated the row's natural height. With it gone,
the row collapsed from 60px to 48px and the title appeared to
jump upward.

Pin a min-h-15 on the <summary> so the height stays constant
through open/close.

* Let's not regress IPv6

* Keep the only real change in schema.rb

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Guillem Arias <accounts@gariasf.com>
Co-authored-by: Guillem Arias Fauste <gariasf@proton.me>
This commit is contained in:
Juan José Mata
2026-05-10 22:13:57 +02:00
committed by GitHub
parent f62aed66e4
commit f6f9feba8a
76 changed files with 1466 additions and 697 deletions

View File

@@ -1,5 +1,5 @@
<div class="<%= container_classes %>">
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %>
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0 mt-0.5" %>
<div class="flex-1 text-sm text-primary space-y-1">
<% if title.present? %>

View File

@@ -0,0 +1,25 @@
<%= link_to connect_path,
class: "bg-container shadow-border-xs hover:bg-surface-inset rounded-xl p-4 flex flex-col gap-2.5 text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300",
data: { turbo_frame: "drawer", turbo_prefetch: "false" }.merge(filter_data) do %>
<div class="flex items-start gap-2.5">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 <%= logo_bg %>">
<span class="text-xs font-bold text-inverse"><%= logo_text %></span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-primary"><%= name %></span>
<%= render "settings/providers/maturity_badge", label: maturity_label %>
</div>
<% if meta_line.present? %>
<p class="text-xs text-secondary mt-0.5"><%= meta_line %></p>
<% end %>
</div>
</div>
<% if tagline.present? %>
<p class="text-sm text-secondary grow leading-snug"><%= tagline %></p>
<% end %>
<div class="flex items-center justify-end gap-1.5 text-sm font-medium text-primary">
<%= t("settings.providers.connect") %>
<%= helpers.icon "arrow-right", size: "sm", class: "!w-3.5 !h-3.5" %>
</div>
<% end %>

View File

@@ -0,0 +1,47 @@
class Settings::ProviderCard < ApplicationComponent
MATURITY_LABELS = {
beta: "settings.providers.maturity.beta",
alpha: "settings.providers.maturity.alpha"
}.freeze
def self.maturity_label(maturity)
key = MATURITY_LABELS[maturity&.to_sym]
I18n.t(key) if key
end
def initialize(provider_key:, name:, tagline: nil, region: nil, kind: nil, tier: nil,
maturity: :stable, logo_bg: "bg-gray-500", logo_text: nil)
@provider_key = provider_key
@name = name
@tagline = tagline
@region = region
@kind = kind
@tier = tier
@maturity = maturity.to_sym
@logo_bg = logo_bg
@logo_text = logo_text || name.first(2).upcase
end
attr_reader :name, :tagline, :logo_bg, :logo_text
def maturity_label
self.class.maturity_label(@maturity)
end
def meta_line
[ @region, @kind, @tier ].compact.join(" · ")
end
def connect_path
helpers.connect_form_settings_providers_path(provider_key: @provider_key)
end
def filter_data
{
providers_filter_target: "card",
provider_name: @name.to_s.downcase,
provider_region: @region.to_s.downcase,
provider_kind: @kind.to_s.downcase
}
end
end

View File

@@ -1,43 +0,0 @@
class Settings::BankSyncController < ApplicationController
layout "settings"
def show
@providers = [
{
name: "Lunch Flow",
description: "US, Canada, UK, EU, Brazil and Asia through multiple open banking providers.",
path: "https://lunchflow.app/features/sure-integration?atp=BiDIYS",
target: "_blank",
rel: "noopener noreferrer"
},
{
name: "Plaid",
description: "US & Canada bank connections with transactions, investments, and liabilities.",
path: "https://github.com/we-promise/sure/blob/main/docs/hosting/plaid.md",
target: "_blank",
rel: "noopener noreferrer"
},
{
name: "SimpleFIN",
description: "US & Canada connections via SimpleFIN protocol.",
path: "https://beta-bridge.simplefin.org",
target: "_blank",
rel: "noopener noreferrer"
},
{
name: "Enable Banking (beta)",
description: "European bank connections via open banking APIs across multiple countries.",
path: "https://enablebanking.com",
target: "_blank",
rel: "noopener noreferrer"
},
{
name: "Sophtron (alpha)",
description: "US & Canada bank, credit card, investment, loan, insurance, utility, and other connections.",
path: "https://www.sophtron.com/",
target: "_blank",
rel: "noopener noreferrer"
}
]
end
end

View File

@@ -1,12 +1,12 @@
class Settings::ProvidersController < ApplicationController
layout "settings"
layout -> { turbo_frame_request? ? "turbo_rails/frame" : "settings" }
before_action :ensure_admin, only: [ :show, :update ]
before_action :ensure_admin, only: [ :show, :update, :sync_all, :sync, :connect_form ]
def show
@breadcrumbs = [
[ "Home", root_path ],
[ "Bank Sync Providers", nil ]
[ "Bank sync", nil ]
]
prepare_show_context
@@ -77,6 +77,61 @@ class Settings::ProvidersController < ApplicationController
render :show, status: :unprocessable_entity
end
def sync_all
family = Current.family
now = Time.current
updated_count = Family
.where(id: family.id)
.where("last_sync_all_attempted_at IS NULL OR last_sync_all_attempted_at <= ?", 30.seconds.ago)
.update_all(last_sync_all_attempted_at: now, updated_at: now)
if updated_count.zero?
return redirect_to settings_providers_path, notice: t("settings.providers.sync_all_recently")
end
SyncAllProvidersJob.perform_later(family.id)
redirect_to settings_providers_path, notice: t("settings.providers.sync_all_in_progress")
end
def sync
provider_key = params[:provider_key]
syncable_type = PANEL_SYNCABLE_TYPES[provider_key]
return redirect_to settings_providers_path unless syncable_type
items = syncable_type.constantize.where(family: Current.family).syncable
scheduled = items.reject(&:syncing?)
scheduled.each(&:sync_later)
notice_key = scheduled.any? ? "settings.providers.sync_provider_in_progress" : "settings.providers.sync_provider_no_items"
redirect_to settings_providers_path, notice: t(notice_key)
end
def connect_form
provider_key = params[:provider_key]
panel = FAMILY_PANELS.find { |p| p[:key] == provider_key }
if panel
@panel_key = panel[:key]
@panel_partial = panel[:partial]
@panel_title = panel[:title]
load_provider_items(provider_key)
return render :connect_form
end
Provider::Factory.ensure_adapters_loaded
config = Provider::ConfigurationRegistry.all.find { |c| c.provider_key.to_s == provider_key }
if config
@panel_title = Provider::Metadata.for(provider_key)[:name] || provider_key.titleize
@provider_configuration = config
return render :connect_form
end
redirect_to settings_providers_path, alert: t("settings.providers.not_found")
rescue ActiveRecord::Encryption::Errors::Configuration
redirect_to settings_providers_path, alert: t("settings.providers.encryption_error.title")
end
private
def provider_params
# Dynamically permit all provider configuration fields
@@ -93,7 +148,9 @@ class Settings::ProvidersController < ApplicationController
end
def ensure_admin
redirect_to settings_providers_path, alert: "Not authorized" unless Current.user.admin?
return if Current.user.admin?
redirect_to root_path, alert: t("settings.providers.not_authorized")
end
# Reload provider configurations after settings update
@@ -119,19 +176,71 @@ class Settings::ProvidersController < ApplicationController
end
end
# Hardcoded family-scoped panels — provider connections are managed through
# their own models (SimplefinItem, LunchflowItem, etc.) rather than global
# settings, so they need custom UI per-provider for connection management,
# status display, and sync actions. The configuration registry excludes
# them (see prepare_show_context).
FAMILY_PANELS = [
{ key: "lunchflow", title: "Lunch Flow", turbo_id: "lunchflow", partial: "lunchflow_panel" },
{ key: "simplefin", title: "SimpleFIN", turbo_id: "simplefin", partial: "simplefin_panel" },
{ key: "enable_banking", title: "Enable Banking", turbo_id: "enable_banking", partial: "enable_banking_panel" },
{ key: "coinstats", title: "CoinStats", turbo_id: "coinstats", partial: "coinstats_panel" },
{ key: "mercury", title: "Mercury", turbo_id: "mercury", partial: "mercury_panel" },
{ key: "coinbase", title: "Coinbase", turbo_id: "coinbase", partial: "coinbase_panel" },
{ key: "binance", title: "Binance", turbo_id: "binance", partial: "binance_panel" },
{ key: "snaptrade", title: "SnapTrade", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" },
{ key: "indexa_capital", title: "Indexa Capital", turbo_id: "indexa_capital", partial: "indexa_capital_panel" },
{ key: "sophtron", title: "Sophtron", turbo_id: "sophtron", partial: "sophtron_panel" }
].freeze
FAMILY_PANEL_KEYS = FAMILY_PANELS.map { |p| p[:key] }.freeze
# Maps panel key → ActiveRecord model name for sync health queries
PANEL_SYNCABLE_TYPES = {
"simplefin" => "SimplefinItem",
"lunchflow" => "LunchflowItem",
"enable_banking" => "EnableBankingItem",
"coinstats" => "CoinstatsItem",
"mercury" => "MercuryItem",
"coinbase" => "CoinbaseItem",
"binance" => "BinanceItem",
"snaptrade" => "SnaptradeItem",
"indexa_capital" => "IndexaCapitalItem",
"sophtron" => "SophtronItem"
}.freeze
def load_provider_items(provider_key)
case provider_key
when "simplefin"
@simplefin_items = Current.family.simplefin_items.ordered
when "lunchflow"
@lunchflow_items = Current.family.lunchflow_items.ordered
when "enable_banking"
@enable_banking_items = Current.family.enable_banking_items.ordered
when "coinstats"
@coinstats_items = Current.family.coinstats_items.ordered
when "mercury"
@mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts)
when "coinbase"
@coinbase_items = Current.family.coinbase_items.ordered
when "binance"
@binance_items = Current.family.binance_items.active.ordered
when "snaptrade"
@snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered
when "indexa_capital"
@indexa_capital_items = Current.family.indexa_capital_items.ordered
when "sophtron"
@sophtron_items = Current.family.sophtron_items.ordered
end
end
# Prepares instance vars needed by the show view and partials
def prepare_show_context
# Load all provider configurations (exclude SimpleFin and Lunchflow, which have their own family-specific panels below)
# Load all provider configurations (exclude family-scoped panels, which have their own UI below)
Provider::Factory.ensure_adapters_loaded
@provider_configurations = Provider::ConfigurationRegistry.all.reject do |config|
config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \
config.provider_key.to_s.casecmp("enable_banking").zero? || \
config.provider_key.to_s.casecmp("sophtron").zero? || \
config.provider_key.to_s.casecmp("coinstats").zero? || \
config.provider_key.to_s.casecmp("mercury").zero? || \
config.provider_key.to_s.casecmp("coinbase").zero? || \
config.provider_key.to_s.casecmp("snaptrade").zero? || \
config.provider_key.to_s.casecmp("indexa_capital").zero?
FAMILY_PANEL_KEYS.any? { |key| config.provider_key.to_s.casecmp(key).zero? }
end
# Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials
@@ -141,9 +250,100 @@ class Settings::ProvidersController < ApplicationController
# Providers page only needs to know whether any Sophtron connections exist with valid credentials
@sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id)
@coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display
@mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts)
@mercury_items = Current.family.mercury_items.active.ordered
@coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display
@snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered
@snaptrade_items = Current.family.snaptrade_items.ordered
@indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id)
@binance_items = Current.family.binance_items.active.ordered
@provider_sync_health = compute_provider_sync_health(family_panel_items)
entries = build_provider_entries
@connected = entries.select { |e| e[:summary][:status] == :ok }
@needs_attention = entries.select { |e| [ :warn, :err ].include?(e[:summary][:status]) }
@available = entries.select { |e| e[:summary][:status] == :off }
@health = view_context.provider_health_strip(connected: @connected, needs_attention: @needs_attention)
end
# Maps each family panel key to the loaded item collection. Used by
# compute_provider_sync_health and build_provider_entries to avoid relying
# on instance_variable_get for control flow.
def family_panel_items
{
"simplefin" => @simplefin_items,
"lunchflow" => @lunchflow_items,
"enable_banking" => @enable_banking_items,
"coinstats" => @coinstats_items,
"mercury" => @mercury_items,
"coinbase" => @coinbase_items,
"binance" => @binance_items,
"snaptrade" => @snaptrade_items,
"indexa_capital" => @indexa_capital_items,
"sophtron" => @sophtron_items
}
end
# Returns a hash mapping provider key → { error:, last_synced_at:, stale: }
# by querying the latest sync per item for each family panel provider.
def compute_provider_sync_health(items_map)
PANEL_SYNCABLE_TYPES.each_with_object({}) do |(key, syncable_type), health|
ids = items_map[key]&.map(&:id)&.compact
next if ids.blank?
health[key] = sync_health_for(syncable_type, ids)
end
end
# Determines error/stale status and last successful sync time for a set of items.
def sync_health_for(syncable_type, item_ids)
# Use window function to get the single latest sync per item (same pattern as ProviderConnectionStatus)
ranked_subq = Sync
.where(syncable_type: syncable_type, syncable_id: item_ids)
.select("syncs.*, ROW_NUMBER() OVER (PARTITION BY syncable_id ORDER BY created_at DESC, id DESC) AS sync_rank")
latest_per_item = Sync.from(ranked_subq, :syncs).where("sync_rank = 1").to_a
has_error = latest_per_item.any? { |s| s.failed? || s.stale? }
last_synced = Sync
.where(syncable_type: syncable_type, syncable_id: item_ids, status: "completed")
.maximum(:completed_at)
stale = !has_error && last_synced.present? && last_synced < 24.hours.ago
{ error: has_error, last_synced_at: last_synced, stale: stale }
end
# Builds a unified list of provider entries (registry-driven configurations
# and hardcoded family panels) with pre-computed status, sorted
# alphabetically by display title. Each entry carries enough data for the
# view to render either a provider_form or a family panel partial.
def build_provider_entries
configuration_entries = @provider_configurations.map do |config|
meta = Provider::Metadata.for(config.provider_key)
{
provider_key: config.provider_key.to_s,
title: meta[:name] || config.provider_key.to_s.titleize,
configuration: config,
maturity: meta[:maturity],
summary: view_context.provider_summary(config.provider_key)
}
end
family_entries = FAMILY_PANELS.map do |panel|
{
provider_key: panel[:key],
title: panel[:title],
turbo_id: panel[:turbo_id],
partial: panel[:partial],
auto_open_param: panel[:auto_open],
maturity: Provider::Metadata.for(panel[:key])[:maturity],
summary: view_context.provider_summary(panel[:key])
}
end
(configuration_entries + family_entries).sort_by { |entry| entry[:title].downcase }
end
end

View File

@@ -2,7 +2,7 @@ module SettingsHelper
SETTINGS_ORDER = [
# General section
{ name: "Accounts", path: :accounts_path },
{ name: "Bank Sync", path: :settings_bank_sync_path },
{ name: "Bank Sync", path: :settings_providers_path, condition: :admin_user? },
{ name: "Preferences", path: :settings_preferences_path },
{ name: "Appearance", path: :settings_appearance_path },
{ name: "Profile Info", path: :settings_profile_path },
@@ -19,7 +19,6 @@ module SettingsHelper
{ name: "LLM Usage", path: :settings_llm_usage_path, condition: :admin_user? },
{ name: "API Key", path: :settings_api_key_path, condition: :admin_user? },
{ name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted_and_admin? },
{ name: "Providers", path: :settings_providers_path, condition: :admin_user? },
{ name: "Imports", path: :imports_path, condition: :admin_user? },
{ name: "Exports", path: :family_exports_path, condition: :admin_user? },
# More section
@@ -45,9 +44,70 @@ module SettingsHelper
}
end
def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, &block)
def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil, &block)
content = capture(&block)
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param }
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param, status: status, meta: meta, actions: actions, badge: badge }
end
def status_pill_classes(status)
pill = "bg-surface-inset text-primary"
case status.to_s.to_sym
when :ok
{ dot: "bg-success", pill: pill }
when :warn
{ dot: "bg-warning", pill: pill }
when :err
{ dot: "bg-destructive", pill: pill }
else
{ dot: "bg-gray-400", pill: pill }
end
end
def provider_summary(provider_key)
key = provider_key.to_s.downcase
case key
when "plaid", "plaid_eu"
configured = @provider_configurations&.find { |c| c.provider_key.to_s.casecmp(key).zero? }&.configured?
configured ? { status: :ok } : { status: :off }
when "simplefin"
return { status: :off } unless @simplefin_items&.any?
sync_based_summary(key)
when "lunchflow"
return { status: :off } unless @lunchflow_items&.any?
sync_based_summary(key)
when "enable_banking"
return { status: :off } unless @enable_banking_items&.any?
enable_banking_summary
when "coinstats"
return { status: :off } unless @coinstats_items&.any?
sync_based_summary(key)
when "mercury"
return { status: :off } unless @mercury_items&.any?
sync_based_summary(key)
when "coinbase"
return { status: :off } unless @coinbase_items&.any?
sync_based_summary(key)
when "binance"
return { status: :off } unless @binance_items&.any?
sync_based_summary(key)
when "snaptrade"
configured_item = @snaptrade_items&.find(&:credentials_configured?)
return { status: :off } unless configured_item
unless configured_item.user_registered?
return { status: :warn, meta: t("settings.providers.meta.registration_needed") }
end
sync_based_summary(key)
when "indexa_capital"
return { status: :off } unless @indexa_capital_items&.any?
sync_based_summary(key)
when "sophtron"
return { status: :off } unless @sophtron_items&.any?
sync_based_summary(key)
else
{ status: :off }
end
end
def settings_nav_footer
@@ -70,7 +130,85 @@ module SettingsHelper
end
end
# Below this many synced accounts, the per-row pills already give the user
# enough at-a-glance signal and the strip is redundant chrome.
HEALTH_STRIP_MIN_ACCOUNTS = 10
# Slim health-strip data for the providers index. Pulls counts from the
# already-resolved entry summaries plus the family's distinct synced-account
# count for the trailing stat. Returns a hash consumed by the
# `settings/providers/_health_strip` partial, or nil when the family has
# fewer than HEALTH_STRIP_MIN_ACCOUNTS connected accounts.
def provider_health_strip(connected:, needs_attention:)
accounts_count = Current.family.accounts.joins(:account_providers).distinct.count
return nil if accounts_count < HEALTH_STRIP_MIN_ACCOUNTS
active_entries = connected + needs_attention
last_synced_at = active_entries.map { |e| e[:summary][:last_synced_at] }.compact.max
{
connected: active_entries.size,
needs_attention: needs_attention.size,
accounts_syncing: accounts_count,
last_synced_at: last_synced_at
}
end
# Strips the leading "about " from `time_ago_in_words` so copy reads as
# "Synced 6 hours ago" instead of "Synced about 6 hours ago".
def concise_time_ago(time)
time_ago_in_words(time).sub(/\Aabout /, "")
end
private
def sync_based_summary(provider_key)
health = @provider_sync_health&.dig(provider_key) || {}
last_synced_at = health[:last_synced_at]
base = if health[:error]
{ status: :err, meta: t("settings.providers.meta.sync_error") }
elsif health[:stale]
{ status: :warn, meta: t("settings.providers.meta.no_recent_sync") }
elsif last_synced_at.present?
{ status: :ok, meta: t("settings.providers.meta.last_synced", time: concise_time_ago(last_synced_at)) }
else
{ status: :ok }
end
base.merge(last_synced_at: last_synced_at)
end
def enable_banking_summary
health = @provider_sync_health&.dig("enable_banking") || {}
last_synced_at = health[:last_synced_at]
return { status: :err, meta: t("settings.providers.meta.sync_error"), last_synced_at: nil } if health[:error]
valid_items = @enable_banking_items&.select(&:session_valid?) || []
# All items have expired/missing sessions — need re-authorization
if valid_items.empty?
return { status: :warn, meta: t("settings.providers.meta.reconsent_required"), last_synced_at: last_synced_at }
end
expiring = valid_items.find do |item|
item.session_expires_at.present? && item.session_expires_at < 7.days.from_now
end
if expiring
days = [ ((expiring.session_expires_at - Time.current) / 1.day).ceil, 1 ].max
return { status: :warn, meta: t("settings.providers.meta.reconsent_needed", count: days), last_synced_at: last_synced_at }
end
return { status: :warn, meta: t("settings.providers.meta.no_recent_sync"), last_synced_at: last_synced_at } if health[:stale]
if last_synced_at.present?
{ status: :ok, meta: t("settings.providers.meta.last_synced", time: concise_time_ago(last_synced_at)), last_synced_at: last_synced_at }
else
{ status: :ok, last_synced_at: nil }
end
end
def not_self_hosted?
!self_hosted?
end

View File

@@ -0,0 +1,68 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="providers-filter"
// Filters provider cards by free-text query and a chip-selected kind.
// Updates the visible-count target on the section heading and toggles
// an empty-state target when no card matches.
export default class extends Controller {
static targets = ["input", "chip", "card", "empty", "count"];
static values = { kind: { type: String, default: "all" } };
connect() {
this.syncChipState();
}
filter() {
const query = this.hasInputTarget
? this.inputTarget.value.toLocaleLowerCase().trim()
: "";
const activeKind = this.kindValue;
let visibleCount = 0;
this.cardTargets.forEach((card) => {
const name = card.dataset.providerName ?? "";
const region = card.dataset.providerRegion ?? "";
const kind = card.dataset.providerKind ?? "";
const haystack = `${name} ${region} ${kind}`;
const matchesQuery = !query || haystack.includes(query);
const matchesKind = activeKind === "all" || kind === activeKind;
const visible = matchesQuery && matchesKind;
card.classList.toggle("hidden", !visible);
if (visible) visibleCount++;
});
if (this.hasCountTarget) {
this.countTarget.textContent = visibleCount;
}
if (this.hasEmptyTarget) {
this.emptyTarget.classList.toggle("hidden", visibleCount > 0);
}
}
selectChip(event) {
this.kindValue = event.currentTarget.dataset.kind ?? "all";
this.syncChipState();
this.filter();
}
clear() {
if (this.hasInputTarget) this.inputTarget.value = "";
this.kindValue = "all";
this.syncChipState();
this.filter();
if (this.hasInputTarget) this.inputTarget.focus();
}
syncChipState() {
if (!this.hasChipTarget) return;
this.chipTargets.forEach((chip) => {
const active = chip.dataset.kind === this.kindValue;
chip.classList.toggle("bg-container", active);
chip.classList.toggle("shadow-border-xs", active);
chip.classList.toggle("text-primary", active);
chip.classList.toggle("text-secondary", !active);
chip.setAttribute("aria-pressed", active ? "true" : "false");
});
}
}

View File

@@ -0,0 +1,9 @@
class SyncAllProvidersJob < ApplicationJob
queue_as :high_priority
sidekiq_options lock: :until_executed, lock_args: ->(args) { [ args.first ] }, on_conflict: :log
def perform(family_id)
family = Family.find_by(id: family_id)
family&.sync_later
end
end

View File

@@ -17,6 +17,7 @@ class Family::Syncer
coinbase_items
coinstats_items
mercury_items
binance_items
snaptrade_items
sophtron_items
].freeze

View File

@@ -0,0 +1,22 @@
class Provider
module Metadata
REGISTRY = {
simplefin: { region: "US", kind: "Bank", maturity: :stable, logo_text: "SF", logo_bg: "bg-blue-600" },
lunchflow: { region: "US", kind: "Bank", maturity: :stable, logo_text: "LF", logo_bg: "bg-orange-500" },
enable_banking: { region: "EU", kind: "Bank", maturity: :beta, logo_text: "EB", logo_bg: "bg-purple-600" },
coinstats: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CS", logo_bg: "bg-pink-600" },
mercury: { region: "US", kind: "Bank", maturity: :beta, logo_text: "ME", logo_bg: "bg-cyan-600" },
coinbase: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CB", logo_bg: "bg-blue-500" },
binance: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "BI", logo_bg: "bg-yellow-600" },
snaptrade: { region: "US / CA", kind: "Investment", maturity: :beta, logo_text: "ST", logo_bg: "bg-green-600" },
indexa_capital: { region: "ES", kind: "Investment", maturity: :alpha, logo_text: "IC", logo_bg: "bg-red-600" },
sophtron: { region: "US", kind: "Bank", maturity: :alpha, logo_text: "SO", logo_bg: "bg-teal-600" },
plaid: { region: "US", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" },
plaid_eu: { name: "Plaid EU", region: "EU", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" }
}.freeze
def self.for(provider_key)
REGISTRY[provider_key.to_sym] || { logo_text: provider_key.to_s.first(2).upcase, logo_bg: "bg-gray-500" }
end
end
end

View File

@@ -80,13 +80,6 @@ class Provider::PlaidAdapter < Provider::Base
# Configuration for Plaid US
configure do
description <<~DESC
Setup instructions:
1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials
2. Your Client ID and Secret Key are required to enable Plaid bank sync for US/CA banks
3. For production use, set environment to 'production', for testing use 'sandbox'
DESC
field :client_id,
label: "Client ID",
required: false,

View File

@@ -19,13 +19,6 @@ class Provider::PlaidEuAdapter
# Configuration for Plaid EU
configure do
description <<~DESC
Setup instructions:
1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials
2. Your Client ID and Secret Key are required to enable Plaid bank sync for European banks
3. For production use, set environment to 'production', for testing use 'sandbox'
DESC
field :client_id,
label: "Client ID",
required: false,

View File

@@ -1,4 +1,4 @@
<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil) %>
<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil) %>
<% if collapsible %>
<details <%= "open" if open %>
class="group bg-container shadow-border-xs rounded-xl p-4"
@@ -7,12 +7,24 @@
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %>
<div>
<h2 class="text-lg font-medium text-primary"><%= title %></h2>
<div class="flex items-center gap-2 flex-wrap">
<h2 class="text-lg font-medium text-primary"><%= title %></h2>
<%= badge if badge.present? %>
</div>
<% if subtitle.present? %>
<p class="text-secondary text-sm"><%= subtitle %></p>
<% end %>
</div>
</div>
<% if status.present? %>
<div class="flex items-center gap-2 shrink-0 group-open:hidden">
<% if meta.present? %>
<span class="text-xs text-subdued"><%= meta %></span>
<% end %>
<%= status %>
<%= actions if actions.present? %>
</div>
<% end %>
</summary>
<div class="space-y-4 mt-4">
<%= content %>

View File

@@ -4,7 +4,7 @@ nav_sections = [
header: t(".general_section_title"),
items: [
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },
{ label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" },
{ label: t(".bank_sync_label"), path: settings_providers_path, icon: "banknote", if: Current.user&.admin? },
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
{ label: t(".appearance_label"), path: settings_appearance_path, icon: "palette" },
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
@@ -30,7 +30,6 @@ nav_sections = [
{ label: "LLM Usage", path: settings_llm_usage_path, icon: "activity" },
{ label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" },
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
{ label: "Providers", path: settings_providers_path, icon: "plug" },
{ label: t(".imports_label"), path: imports_path, icon: "download" },
{ label: t(".exports_label"), path: family_exports_path, icon: "upload" },
{ label: "SSO Providers", path: admin_sso_providers_path, icon: "key-round", if: Current.user&.super_admin? },

View File

@@ -1,33 +0,0 @@
<%# locals: (provider_link:) %>
<%# Assign distinct colors to each provider %>
<% provider_colors = {
"Lunch Flow" => "#6471eb",
"Plaid" => "#4da568",
"SimpleFin" => "#e99537",
"Enable Banking" => "#6471eb",
"CoinStats" => "#FF9332", # https://coinstats.app/press-kit/
"Sophtron" => "#1E90FF"
} %>
<% provider_color = provider_colors[provider_link[:name]] || "#6B7280" %>
<%= link_to provider_link[:path],
target: provider_link[:target],
rel: provider_link[:rel],
class: "flex justify-between items-center p-4 bg-container hover:bg-container-hover transition-colors" do %>
<div class="flex w-full items-center gap-2.5">
<%= render partial: "shared/color_avatar", locals: { name: provider_link[:name], color: provider_color } %>
<div class="flex flex-col">
<p class="text-primary text-sm font-medium">
<%= provider_link[:name] %>
</p>
<p class="text-secondary text-xs">
<%= provider_link[:description] %>
</p>
</div>
</div>
<div class="justify-self-end">
<%= icon("arrow-right", size: "sm", class: "text-secondary") %>
</div>
<% end %>

View File

@@ -1,26 +0,0 @@
<%= content_for :page_title, "Bank Sync" %>
<div class="bg-container rounded-xl shadow-border-xs p-4">
<% if @providers.any? %>
<div class="rounded-xl bg-container-inset space-y-1 p-1">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
<p>PROVIDERS</p>
<span class="text-subdued">&middot;</span>
<p><%= @providers.count %></p>
</div>
<div class="bg-container rounded-lg shadow-border-xs">
<div class="overflow-hidden rounded-lg">
<%= render partial: "provider_link", collection: @providers, spacer_template: "shared/ruler" %>
</div>
</div>
</div>
<% else %>
<div class="flex justify-center items-center py-20">
<div class="text-center flex flex-col items-center max-w-[300px]">
<p class="text-primary mb-1 font-medium text-sm">No providers configured</p>
<p class="text-secondary text-sm">Configure providers to link your bank accounts.</p>
</div>
</div>
<% end %>
</div>

View File

@@ -1,32 +1,39 @@
<div class="space-y-4">
<% items = local_assigns[:binance_items] || @binance_items || Current.family.binance_items.active.ordered %>
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium"><%= t("settings.providers.binance_panel.setup_instructions") %></p>
<ol>
<li><%= t("settings.providers.binance_panel.step1_html").html_safe %></li>
<li><%= t("settings.providers.binance_panel.step2") %></li>
<li><%= t("settings.providers.binance_panel.step3") %></li>
</ol>
<p class="text-destructive text-xs font-medium"><%= t("settings.providers.binance_panel.no_withdraw_warning") %></p>
</div>
<%= render DS::Alert.new(
variant: :warning,
message: safe_join([
content_tag(:p, t("settings.providers.binance_panel.no_withdraw_title"), class: "font-medium"),
content_tag(:p, t("settings.providers.binance_panel.no_withdraw_body"), class: "mt-1")
])
) %>
<div class="bg-surface border border-primary p-3 rounded-lg text-sm">
<p class="font-medium text-primary"><%= t("settings.providers.binance_panel.ip_hint_title") %></p>
<p class="text-secondary mt-1"><%= t("settings.providers.binance_panel.ip_hint_body") %></p>
<% server_ip = ENV["BINANCE_EGRESS_IP"].presence %>
<% if server_ip %>
<code class="mt-1 block text-xs bg-container-inset px-2 py-1 rounded font-mono text-primary"><%= server_ip %></code>
<% else %>
<p class="mt-1 text-xs text-secondary italic"><%= t("settings.providers.binance_panel.ip_hint_contact_admin") %></p>
<% end %>
<div class="space-y-2">
<%= render "settings/providers/setup_steps",
steps: [
t("settings.providers.binance_panel.step1_html").html_safe,
t("settings.providers.binance_panel.step2"),
t("settings.providers.binance_panel.step3")
] %>
<div class="bg-surface-inset rounded-xl p-4 text-sm">
<p class="text-xs font-medium uppercase text-subdued tracking-wider mb-2">
<%= t("settings.providers.binance_panel.ip_hint_title") %>
</p>
<p class="text-secondary"><%= t("settings.providers.binance_panel.ip_hint_body") %></p>
<% server_ip = ENV["BINANCE_EGRESS_IP"].presence %>
<% if server_ip %>
<code class="mt-2 block text-xs bg-container px-2 py-1 rounded font-mono text-primary"><%= server_ip %></code>
<% else %>
<p class="mt-1 text-xs text-secondary italic"><%= t("settings.providers.binance_panel.ip_hint_contact_admin") %></p>
<% end %>
</div>
</div>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= h(error_msg) %>"><%= error_msg %></p>
</div>
<%= render DS::Alert.new(message: error_msg, variant: :error) %>
<% end %>
<% if items.any? %>
@@ -94,13 +101,4 @@
<% end %>
<% end %>
<div class="flex items-center gap-2">
<% if items.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary"><%= t("settings.providers.binance_panel.status_connected") %></p>
<% else %>
<div class="w-2 h-2 bg-tertiary rounded-full"></div>
<p class="text-sm text-secondary"><%= t("settings.providers.binance_panel.status_not_connected") %></p>
<% end %>
</div>
</div>

View File

@@ -1,20 +1,16 @@
<div id="coinbase-providers-panel" class="space-y-4">
<% items = local_assigns[:coinbase_items] || @coinbase_items || Current.family.coinbase_items.active.ordered %>
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium"><%= t("settings.providers.coinbase_panel.setup_instructions") %></p>
<ol>
<li><%= t("settings.providers.coinbase_panel.step1_html").html_safe %></li>
<li><%= t("settings.providers.coinbase_panel.step2") %></li>
<li><%= t("settings.providers.coinbase_panel.step3") %></li>
</ol>
</div>
<%= render "settings/providers/setup_steps",
steps: [
t("settings.providers.coinbase_panel.step1_html").html_safe,
t("settings.providers.coinbase_panel.step2"),
t("settings.providers.coinbase_panel.step3")
] %>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<%= render DS::Alert.new(message: error_msg, variant: :error) %>
<% end %>
<% if items.any? %>
@@ -82,13 +78,4 @@
<% end %>
<% end %>
<div class="flex items-center gap-2">
<% if items.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary"><%= t("settings.providers.coinbase_panel.status_connected") %></p>
<% else %>
<div class="w-2 h-2 bg-tertiary rounded-full"></div>
<p class="text-sm text-secondary"><%= t("settings.providers.coinbase_panel.status_not_connected") %></p>
<% end %>
</div>
</div>

View File

@@ -1,18 +1,14 @@
<div class="space-y-4">
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium"><%= t("coinstats_items.new.setup_instructions") %></p>
<ol>
<li><%= t("coinstats_items.new.step1_html").html_safe %></li>
<li><%= t("coinstats_items.new.step2") %></li>
<li><%= t("coinstats_items.new.step3_html", accounts_url: accounts_path).html_safe %></li>
</ol>
</div>
<%= render "settings/providers/setup_steps",
steps: [
t("coinstats_items.new.step1_html").html_safe,
t("coinstats_items.new.step2"),
t("coinstats_items.new.step3_html", accounts_url: accounts_path).html_safe
] %>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<%= render DS::Alert.new(message: error_msg, variant: :error) %>
<% end %>
<%
@@ -41,14 +37,4 @@
</div>
<% end %>
<% items = local_assigns[:coinstats_items] || @coinstats_items || Current.family.coinstats_items.where.not(api_key: [nil, ""]) %>
<div class="flex items-center gap-2">
<% if items&.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary"><%= t("coinstats_items.new.status_configured_html", accounts_url: accounts_path).html_safe %></p>
<% else %>
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
<p class="text-sm text-secondary"><%= t("coinstats_items.new.status_not_configured") %></p>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,43 @@
<%# locals: (entry:, open:) %>
<%
status = entry[:summary][:status]
meta = entry[:summary][:meta]
last_synced = entry[:summary][:last_synced_at]
border_class =
case status
when :warn then "border border-warning/25"
when :err then "border border-destructive/25"
else "border border-transparent"
end
sync_action = entry[:partial].present? ? render("settings/providers/sync_button", provider_key: entry[:provider_key], last_synced_at: last_synced) : nil
status_pill = render("settings/providers/status_pill", status: status)
maturity_lbl = Settings::ProviderCard.maturity_label(entry[:maturity])
details_data = entry[:auto_open_param].present? ? { controller: "auto-open", auto_open_param_value: entry[:auto_open_param] } : {}
%>
<%= tag.details open: open,
class: "group bg-container shadow-border-xs rounded-xl #{border_class}",
data: details_data do %>
<summary class="flex items-center gap-3 min-h-15 px-4 py-3.5 cursor-pointer rounded-xl list-none [&::-webkit-details-marker]:hidden">
<%= icon "chevron-right", size: "sm", class: "!w-3.5 !h-3.5 text-secondary group-open:rotate-90 transition-transform" %>
<div class="flex items-center gap-2 flex-wrap min-w-0 flex-1">
<h3 class="text-sm font-medium text-primary"><%= entry[:title] %></h3>
<%= render "settings/providers/maturity_badge", label: maturity_lbl %>
</div>
<div class="flex items-center gap-2 shrink-0 group-open:hidden">
<% if meta.present? %>
<span class="text-xs text-subdued"><%= meta %></span>
<% end %>
<%= status_pill %>
<%= sync_action if sync_action %>
</div>
</summary>
<div class="space-y-4 mt-4 px-4 pb-4">
<% if entry[:configuration] %>
<%= render "settings/providers/provider_form", configuration: entry[:configuration] %>
<% else %>
<turbo-frame id="<%= entry[:turbo_id] %>-providers-panel">
<%= render "settings/providers/#{entry[:partial]}" %>
</turbo-frame>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,22 @@
<%# locals: (provider_key:, title:) %>
<% meta = provider_key.present? ? Provider::Metadata.for(provider_key) : nil %>
<% maturity_label = meta ? Settings::ProviderCard.maturity_label(meta[:maturity]) : nil %>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<% if meta && meta[:logo_bg].present? %>
<span class="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 <%= meta[:logo_bg] %>">
<span class="text-xs font-bold text-inverse"><%= meta[:logo_text] %></span>
</span>
<% end %>
<h2 class="text-lg font-medium text-primary truncate"><%= title %></h2>
<%= render "settings/providers/maturity_badge", label: maturity_label %>
</div>
<%= render DS::Button.new(
variant: "icon",
class: "ml-auto hidden lg:flex",
icon: "x",
title: t("common.close"),
aria_label: t("common.close"),
data: { action: "DS--dialog#close" }
) %>
</div>

View File

@@ -1,27 +1,18 @@
<div class="space-y-4">
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium">Setup instructions:</p>
<ol>
<li>Visit your <a href="https://enablebanking.com" target="_blank" rel="noopener noreferrer" class="link">Enable Banking</a> developer account to get your credentials</li>
<li>Select your country code from the dropdown below</li>
<li>Enter your Application ID and paste your Client Certificate (including the private key)</li>
<li>Click Save Configuration, then use "Add Connection" to link your bank</li>
<li><%= t("settings.providers.enable_banking_panel.callback_url_instruction", callback_url: enable_banking_callback_url) %></li>
</ol>
<p class="text-primary font-medium">Field descriptions:</p>
<ul>
<li><strong>Country Code:</strong> ISO 3166-1 alpha-2 country code (e.g., GB, DE, FR) - determines available banks</li>
<li><strong>Application ID:</strong> The ID generated in your Enable Banking developer account</li>
<li><strong>Client Certificate:</strong> The certificate generated when you created your application (must include the private key)</li>
</ul>
</div>
<%
eb_link = link_to("Enable Banking", "https://enablebanking.com", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline")
%>
<%= render "settings/providers/setup_steps",
steps: [
t("settings.providers.enable_banking_panel.step_1_html", link: eb_link),
t("settings.providers.enable_banking_panel.step_2"),
t("settings.providers.enable_banking_panel.step_3"),
t("settings.providers.enable_banking_panel.callback_url_instruction", callback_url: enable_banking_callback_url)
] %>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<%= render DS::Alert.new(message: error_msg, variant: :error) %>
<% end %>
<%
@@ -78,10 +69,13 @@
{ label: "Country", class: "form-field__input" } %>
<% if has_authenticated_connections && !is_new_record %>
<div class="p-3 rounded-md bg-warning/10 text-warning text-sm">
<p class="font-medium">Configuration locked</p>
<p class="text-xs mt-1">Credentials cannot be changed while you have active bank connections. Remove all connections first to update credentials.</p>
</div>
<%= render DS::Alert.new(
variant: :warning,
message: safe_join([
content_tag(:p, "Configuration locked", class: "font-medium"),
content_tag(:p, "Disconnect all linked banks before changing these credentials.", class: "mt-1")
])
) %>
<% end %>
<%= form.text_field :application_id,
@@ -98,7 +92,7 @@
disabled: has_authenticated_connections && !is_new_record %>
<div class="flex justify-end">
<%= form.submit is_new_record ? "Save Configuration" : "Update Configuration",
<%= form.submit is_new_record ? "Save and connect" : "Update connection",
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
</div>
<% end %>
@@ -169,7 +163,7 @@
<%= link_to select_bank_enable_banking_item_path(item),
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-inverse button-bg-primary hover:button-bg-primary-hover transition-colors",
data: { turbo_frame: "modal" } do %>
Connect Bank
Connect bank
<% end %>
<% end %>

View File

@@ -0,0 +1,12 @@
<%# locals: (title:, count: nil, description: nil, anchor: nil) %>
<%= tag.div id: anchor.presence, class: "flex items-baseline justify-between gap-3 mt-6 mb-1.5 px-1" do %>
<h2 class="text-xs font-medium text-secondary uppercase flex items-baseline gap-2">
<%= title %>
<% if count %>
<span class="text-subdued font-normal normal-case tabular-nums">· <%= count %></span>
<% end %>
</h2>
<% if description.present? %>
<p class="text-xs text-secondary"><%= description %></p>
<% end %>
<% end %>

View File

@@ -0,0 +1,28 @@
<%# locals: (connected:, needs_attention:, accounts_syncing:, last_synced_at:) %>
<div class="bg-container shadow-border-xs rounded-xl flex items-center gap-5 px-3.5 py-2.5 text-sm mt-4 mb-5">
<span class="inline-flex items-center gap-2">
<%= icon "check", size: "sm", class: "!w-3.5 !h-3.5 text-success" %>
<span class="font-medium tabular-nums"><%= connected %></span>
<span class="text-secondary"><%= t("settings.providers.health_strip.connected") %></span>
</span>
<% if needs_attention.positive? %>
<span class="h-3.5 border-l border-secondary"></span>
<span class="inline-flex items-center gap-2">
<%= icon "circle-alert", size: "sm", class: "!w-3.5 !h-3.5 text-warning" %>
<span class="font-medium tabular-nums"><%= needs_attention %></span>
<span class="text-secondary"><%= t("settings.providers.health_strip.needs_attention") %></span>
</span>
<% end %>
<% if accounts_syncing.positive? %>
<span class="h-3.5 border-l border-secondary"></span>
<span class="inline-flex items-center gap-2 text-secondary">
<span class="font-medium tabular-nums"><%= accounts_syncing %></span>
<span><%= t("settings.providers.health_strip.accounts_syncing") %></span>
</span>
<% end %>
<% if last_synced_at %>
<span class="ml-auto text-xs text-subdued">
<%= t("settings.providers.health_strip.last_synced", time: concise_time_ago(last_synced_at)) %>
</span>
<% end %>
</div>

View File

@@ -1,18 +1,14 @@
<div class="space-y-4">
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium"><%= t("indexa_capital_items.panel.setup_instructions") %></p>
<ol>
<li><%= t("indexa_capital_items.panel.step_1") %></li>
<li><%= t("indexa_capital_items.panel.step_2") %></li>
<li><%= t("indexa_capital_items.panel.step_3") %></li>
</ol>
</div>
<%= render "settings/providers/setup_steps",
steps: [
t("indexa_capital_items.panel.step_1"),
t("indexa_capital_items.panel.step_2"),
t("indexa_capital_items.panel.step_3")
] %>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<%= render DS::Alert.new(message: error_msg, variant: :error) %>
<% end %>
<%
@@ -27,14 +23,11 @@
data: { turbo: true },
class: "space-y-3" do |form| %>
<div class="bg-surface border border-primary p-3 rounded-lg">
<p class="text-sm font-medium text-primary mb-2"><%= t("indexa_capital_items.panel.fields.api_token.label") %></p>
<p class="text-xs text-secondary mb-2"><%= t("indexa_capital_items.panel.fields.api_token.description") %></p>
<%= form.text_field :api_token,
label: t("indexa_capital_items.panel.fields.api_token.label"),
placeholder: is_new_record ? t("indexa_capital_items.panel.fields.api_token.placeholder_new") : t("indexa_capital_items.panel.fields.api_token.placeholder_update"),
type: :password %>
</div>
<%= form.text_field :api_token,
label: t("indexa_capital_items.panel.fields.api_token.label"),
placeholder: is_new_record ? t("indexa_capital_items.panel.fields.api_token.placeholder_new") : t("indexa_capital_items.panel.fields.api_token.placeholder_update"),
type: :password %>
<p class="text-xs text-secondary px-1 -mt-1"><%= t("indexa_capital_items.panel.fields.api_token.description") %></p>
<details class="group">
<summary class="text-sm text-secondary cursor-pointer hover:text-primary transition-colors">
@@ -64,14 +57,4 @@
</div>
<% end %>
<% items = local_assigns[:indexa_capital_items] || @indexa_capital_items || Current.family.indexa_capital_items.where.not(username: [nil, ""], document: [nil, ""], password: [nil, ""]).or(Current.family.indexa_capital_items.where.not(api_token: [nil, ""])) %>
<div class="flex items-center gap-2">
<% if items&.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary"><%= t("indexa_capital_items.panel.status_configured_html", accounts_path: accounts_path).html_safe %></p>
<% else %>
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
<p class="text-sm text-secondary"><%= t("indexa_capital_items.panel.status_not_configured") %></p>
<% end %>
</div>
</div>

View File

@@ -1,24 +1,17 @@
<div class="space-y-4">
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium">Setup instructions:</p>
<ol>
<li>Visit <a href="https://www.lunchflow.app/?atp=BiDIYS" target="_blank" rel="noopener noreferrer" class="link">Lunch Flow</a> to get your API key</li>
<li>Paste your API key below and click the Save button</li>
<li>After a successful connection, go to the Accounts tab to set up new accounts and link them to your existing ones</li>
</ol>
<p class="text-primary font-medium">Field descriptions:</p>
<ul>
<li><strong>API Key:</strong> Your Lunch Flow API key for authentication (required)</li>
<li><strong>Base URL:</strong> Base URL for Lunch Flow API (optional, defaults to https://lunchflow.app/api/v1)</li>
</ul>
</div>
<%
lf_link = link_to("Lunch Flow", "https://www.lunchflow.app/?atp=BiDIYS", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline")
%>
<%= render "settings/providers/setup_steps",
steps: [
t("settings.providers.lunchflow_panel.step_1_html", link: lf_link),
t("settings.providers.lunchflow_panel.step_2"),
t("settings.providers.lunchflow_panel.step_3")
] %>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<%= render DS::Alert.new(message: error_msg, variant: :error) %>
<% end %>
<%
@@ -44,19 +37,9 @@
value: lunchflow_item.base_url %>
<div class="flex justify-end">
<%= form.submit is_new_record ? "Save Configuration" : "Update Configuration",
<%= form.submit is_new_record ? "Save and connect" : "Update connection",
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
</div>
<% end %>
<% items = local_assigns[:lunchflow_items] || @lunchflow_items || Current.family.lunchflow_items.where.not(api_key: nil) %>
<div class="flex items-center gap-2">
<% if items&.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary">Configured and ready to use. Visit the <a href="<%= accounts_path %>" class="link">Accounts</a> tab to manage and set up accounts.</p>
<% else %>
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
<p class="text-sm text-secondary">Not configured</p>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,4 @@
<%# locals: (label:) %>
<% if label %>
<span class="text-xs font-medium px-1.5 py-px rounded-full bg-surface-inset border border-tertiary text-secondary"><%= label %></span>
<% end %>

View File

@@ -2,26 +2,18 @@
<% active_items = local_assigns[:mercury_items] || @mercury_items || Current.family.mercury_items.active.ordered %>
<% credentialed_items = active_items.select(&:credentials_configured?) %>
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium"><%= t("mercury_items.provider_panel.setup_title") %></p>
<ol>
<li><%= t("mercury_items.provider_panel.instructions.sign_in_html", link: link_to("Mercury", "https://mercury.com", target: "_blank", rel: "noopener noreferrer", class: "link")) %></li>
<li><%= t("mercury_items.provider_panel.instructions.open_tokens") %></li>
<li><%= t("mercury_items.provider_panel.instructions.create_token") %></li>
<li><%= t("mercury_items.provider_panel.instructions.whitelist_ip_html") %></li>
<li><%= t("mercury_items.provider_panel.instructions.copy_token_html") %></li>
</ol>
<p class="text-sm text-subdued mt-2">
<%= t("mercury_items.provider_panel.sandbox_note_html") %>
</p>
</div>
<%= render "settings/providers/setup_steps",
steps: [
t("mercury_items.provider_panel.instructions.sign_in_html", link: link_to("Mercury", "https://mercury.com", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline")).html_safe,
t("mercury_items.provider_panel.instructions.open_tokens"),
t("mercury_items.provider_panel.instructions.create_token"),
t("mercury_items.provider_panel.instructions.whitelist_ip_html").html_safe,
t("mercury_items.provider_panel.instructions.copy_token_html").html_safe
] %>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<%= render DS::Alert.new(message: error_msg, variant: :error) %>
<% end %>
<% if active_items.any? %>
@@ -96,47 +88,38 @@
</div>
<% end %>
<details <%= "open" unless active_items.any? %> class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center gap-2 text-sm font-medium text-primary">
<%= icon "plus" %>
<% mercury_item = Current.family.mercury_items.build(name: t("mercury_items.provider_panel.default_connection_name")) %>
<% if active_items.any? %>
<h3 class="flex items-center gap-2 text-sm font-medium text-primary mt-4">
<%= icon "plus", size: "sm" %>
<%= t("mercury_items.provider_panel.add_connection") %>
</summary>
</h3>
<% end %>
<%= styled_form_with model: mercury_item,
url: mercury_items_path,
scope: :mercury_item,
method: :post,
data: { turbo: true },
class: "space-y-3" do |form| %>
<%= form.text_field :name,
label: t("mercury_items.provider_panel.connection_name_label"),
placeholder: t("mercury_items.provider_panel.connection_name_placeholder") %>
<% mercury_item = Current.family.mercury_items.build(name: t("mercury_items.provider_panel.default_connection_name")) %>
<%= styled_form_with model: mercury_item,
url: mercury_items_path,
scope: :mercury_item,
method: :post,
data: { turbo: true },
class: "space-y-3 mt-4" do |form| %>
<%= form.text_field :name,
label: t("mercury_items.provider_panel.connection_name_label"),
placeholder: t("mercury_items.provider_panel.connection_name_placeholder") %>
<%= form.text_field :token,
label: t("mercury_items.provider_panel.token_label"),
placeholder: t("mercury_items.provider_panel.token_placeholder"),
type: :password,
value: nil %>
<%= form.text_field :token,
label: t("mercury_items.provider_panel.token_label"),
placeholder: t("mercury_items.provider_panel.token_placeholder"),
type: :password,
value: nil %>
<%= form.text_field :base_url,
label: t("mercury_items.provider_panel.base_url_label"),
placeholder: t("mercury_items.provider_panel.base_url_placeholder") %>
<p class="text-xs text-secondary px-1 -mt-1"><%= t("mercury_items.provider_panel.sandbox_note_html").html_safe %></p>
<%= form.text_field :base_url,
label: t("mercury_items.provider_panel.base_url_label"),
placeholder: t("mercury_items.provider_panel.base_url_placeholder") %>
<div class="flex justify-end">
<%= form.submit t("mercury_items.provider_panel.add_connection"),
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %>
</div>
<% end %>
<div class="flex justify-end">
<%= form.submit t("mercury_items.provider_panel.add_connection"),
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %>
</div>
<% end %>
</details>
<div class="flex items-center gap-2">
<% if credentialed_items.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary"><%= t("mercury_items.provider_panel.configured_html", accounts_link: link_to(t("mercury_items.provider_panel.accounts_link"), accounts_path, class: "link")) %></p>
<% else %>
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
<p class="text-sm text-secondary"><%= t("mercury_items.provider_panel.not_configured") %></p>
<% end %>
</div>
</div>

View File

@@ -1,34 +1,35 @@
<%
# Parameters:
# - configuration: Provider::Configurable::Configuration object
provider_key = configuration.provider_key.to_s
setup_steps_data =
if %w[plaid plaid_eu].include?(provider_key)
plaid_link = link_to("Plaid Dashboard", "https://dashboard.plaid.com/team/keys", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline")
step_1_key = "settings.providers.#{provider_key}_panel.step_1_html"
[
t(step_1_key, link: plaid_link),
t("settings.providers.plaid_panel.step_2"),
t("settings.providers.plaid_panel.step_3")
]
end
%>
<div class="space-y-4">
<div>
<% if configuration.provider_description.present? %>
<div class="text-sm text-secondary mb-4 prose prose-sm">
<%= markdown(configuration.provider_description).html_safe %>
</div>
<% end %>
<% if setup_steps_data %>
<%= render "settings/providers/setup_steps", steps: setup_steps_data %>
<% elsif configuration.provider_description.present? %>
<div class="text-sm text-secondary prose prose-sm">
<%= markdown(configuration.provider_description).html_safe %>
</div>
<% end %>
<% env_configured = configuration.fields.any? { |f| f.env_key && ENV[f.env_key].present? } %>
<% if env_configured %>
<p class="text-sm text-secondary">
Configuration can be set via environment variables or overridden below.
</p>
<% end %>
<% if configuration.fields.any? { |f| f.description.present? } %>
<p class="text-secondary text-sm mb-4">Field descriptions:</p>
<ul class="text-sm text-secondary mb-4 list-disc ml-6 space-y-2">
<% configuration.fields.each do |field| %>
<% if field.description.present? %>
<li><strong><%= field.label %>:</strong> <%= field.description %></li>
<% end %>
<% end %>
</ul>
<% end %>
</div>
<% env_configured = configuration.fields.any? { |f| f.env_key && ENV[f.env_key].present? } %>
<% if env_configured %>
<p class="text-sm text-secondary">
Configuration can be set via environment variables or overridden below.
</p>
<% end %>
<%= styled_form_with model: Setting.new,
url: settings_providers_path,
@@ -37,50 +38,34 @@
<% configuration.fields.each do |field| %>
<%
env_value = ENV[field.env_key] if field.env_key
# Use dynamic hash-style access - works without explicit field declaration
setting_value = Setting[field.setting_key]
# Show the setting value if it exists, otherwise show ENV value
# This allows users to see what they've overridden
current_value = setting_value.presence || env_value
# Mask secret values if they exist
display_value = if field.secret && current_value.present?
"********"
else
current_value
end
# Determine input type
input_type = field.secret ? "password" : "text"
# Don't disable fields - allow overriding ENV variables
disabled = false
%>
<%= form.text_field field.setting_key,
label: field.label,
type: input_type,
placeholder: field.default || (field.required ? "" : "Optional"),
value: display_value,
disabled: disabled %>
<div class="space-y-1">
<%= form.text_field field.setting_key,
label: field.label,
type: input_type,
placeholder: field.default || (field.required ? "" : "Optional"),
value: display_value %>
<% if field.description.present? %>
<p class="text-xs text-secondary px-1"><%= field.description %></p>
<% end %>
</div>
<% end %>
<div class="flex justify-end">
<%= form.submit "Save Configuration",
<%= form.submit "Save and connect",
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
</div>
</div>
<% end %>
<%# Show configuration status %>
<div class="flex items-center gap-2 mt-4">
<% if configuration.configured? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary">Configured and ready to use</p>
<% else %>
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
<p class="text-sm text-secondary">Not configured</p>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,27 @@
<div class="flex flex-wrap items-center gap-2.5 mt-5 mb-3">
<div class="relative flex-1 min-w-[200px]">
<input type="search"
autocomplete="off"
data-providers-filter-target="input"
data-action="input->providers-filter#filter"
aria-label="<%= t("settings.providers.search_filters.aria_label") %>"
placeholder="<%= t("settings.providers.search_filters.placeholder") %>"
class="block w-full border border-secondary rounded-md py-2.5 pl-10 pr-3 bg-container focus:ring-gray-500 sm:text-sm">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<%= icon "search", class: "text-secondary" %>
</div>
</div>
<div class="inline-flex items-center gap-1 p-1 bg-surface-inset rounded-xl">
<% %w[all bank crypto investment].each do |kind| %>
<% active = kind == "all" %>
<button type="button"
data-providers-filter-target="chip"
data-action="click->providers-filter#selectChip"
data-kind="<%= kind %>"
aria-pressed="<%= active %>"
class="px-2.5 py-1 text-xs font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100 <%= active ? "bg-container shadow-border-xs text-primary" : "text-secondary" %>">
<%= t("settings.providers.search_filters.chips.#{kind}") %>
</button>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,26 @@
<%# locals: (steps:, help: nil, eyebrow: nil) %>
<%# steps: array of strings (or html_safe strings; caller is responsible for safety).
help: optional hash { url:, text: } rendered under the steps with a small divider + book icon.
eyebrow: optional override for the localized "SETUP" eyebrow label. %>
<div class="bg-surface-inset rounded-xl p-4">
<p class="text-xs font-medium uppercase text-subdued tracking-wider mb-2">
<%= eyebrow.presence || t("settings.providers.setup_steps.eyebrow") %>
</p>
<ol class="list-none p-0 m-0 space-y-2">
<% steps.each_with_index do |step, i| %>
<li class="relative pl-6 text-sm leading-relaxed text-primary">
<span class="absolute left-0 top-0.5 w-4 text-center font-mono text-xs tabular-nums text-subdued"><%= i + 1 %></span>
<%= step %>
</li>
<% end %>
</ol>
<% if help %>
<div class="mt-2.5 pt-2.5 border-t border-alpha-black-100 text-xs text-secondary flex items-center gap-1.5">
<%= icon "book-open", size: "sm", class: "!w-3 !h-3" %>
<span>
<%= t("settings.providers.setup_steps.need_help") %>
<%= link_to help[:text], help[:url], class: "text-primary font-medium", target: "_blank", rel: "noopener noreferrer" %>
</span>
</div>
<% end %>
</div>

View File

@@ -1,22 +1,16 @@
<div class="space-y-4">
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium">Setup instructions:</p>
<ol>
<li>Visit <a href="https://beta-bridge.simplefin.org" target="_blank" rel="noopener noreferrer" class="link">SimpleFIN Bridge</a> to get your one-time setup token</li>
<li>Paste the token below and click the Save button to enable SimpleFIN bank data sync</li>
<li>After a successful connection, go to the Accounts tab to set up new accounts and link them to your existing ones</li>
</ol>
<p class="text-primary font-medium">Field descriptions:</p>
<ul>
<li><strong>Setup Token:</strong> Your SimpleFIN one-time setup token from SimpleFIN Bridge (consumed on first use)</li>
</ul>
</div>
<%
sf_link = link_to("SimpleFIN Bridge", "https://beta-bridge.simplefin.org", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline")
%>
<%= render "settings/providers/setup_steps",
steps: [
t("settings.providers.simplefin_panel.step_1_html", link: sf_link),
t("settings.providers.simplefin_panel.step_2"),
t("settings.providers.simplefin_panel.step_3")
] %>
<% if defined?(@error_message) && @error_message.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= @error_message %>"><%= @error_message %></p>
</div>
<%= render DS::Alert.new(message: @error_message, variant: :error) %>
<% end %>
<%= styled_form_with model: SimplefinItem.new,
@@ -31,18 +25,9 @@
type: :password %>
<div class="flex justify-end">
<%= form.submit "Save Configuration",
<%= form.submit "Save and connect",
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
</div>
<% end %>
<div class="flex items-center gap-2">
<% if @simplefin_items&.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary">Configured and ready to use. Visit the <a href="<%= accounts_path %>" class="link">Accounts</a> tab to manage and set up accounts.</p>
<% else %>
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
<p class="text-sm text-secondary">Not configured</p>
<% end %>
</div>
</div>

View File

@@ -1,23 +1,17 @@
<div class="space-y-4">
<div class="prose prose-sm text-secondary">
<p><%= t("providers.snaptrade.description") %></p>
<%= render DS::Alert.new(message: t("providers.snaptrade.free_tier_warning"), variant: :warning) %>
<p class="text-primary font-medium"><%= t("providers.snaptrade.setup_title") %></p>
<ol>
<li><%= t("providers.snaptrade.step_1_html") %></li>
<li><%= t("providers.snaptrade.step_2") %></li>
<li><%= t("providers.snaptrade.step_3") %></li>
<li><%= t("providers.snaptrade.step_4") %></li>
</ol>
<p class="text-warning text-sm"><%= icon("alert-triangle", class: "inline-block w-4 h-4 mr-1") %><%= t("providers.snaptrade.free_tier_warning") %></p>
</div>
<%= render "settings/providers/setup_steps",
steps: [
t("providers.snaptrade.step_1_html").html_safe,
t("providers.snaptrade.step_2"),
t("providers.snaptrade.step_3"),
t("providers.snaptrade.step_4")
] %>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<%= render DS::Alert.new(message: error_msg, variant: :error) %>
<% end %>
<%
@@ -51,56 +45,49 @@
<% items = local_assigns[:snaptrade_items] || @snaptrade_items || Current.family.snaptrade_items.where.not(client_id: [nil, ""]) %>
<div class="border-t border-primary pt-4 mt-4">
<% if items&.any? %>
<% item = items.first %>
<% if item.user_registered? %>
<details class="group"
data-controller="lazy-load"
data-action="toggle->lazy-load#toggled"
data-lazy-load-url-value="<%= connections_snaptrade_item_path(item) %>"
data-lazy-load-auto-open-param-value="manage">
<summary class="flex items-center justify-between cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary">
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
<% if item.unlinked_accounts_count > 0 %>
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
<% end %>
</p>
</div>
<span class="flex items-center gap-1 text-sm text-secondary hover:text-primary">
<%= t("providers.snaptrade.manage_connections") %>
<%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %>
</span>
</summary>
<div class="mt-3 space-y-3" data-lazy-load-target="content">
<p class="text-xs text-secondary">
<%= t("providers.snaptrade.connection_limit_info") %>
</p>
<div data-lazy-load-target="loading" class="flex items-center gap-2 text-sm text-secondary py-2">
<%= icon "loader-2", class: "w-4 h-4 animate-spin" %>
<%= t("providers.snaptrade.loading_connections") %>
</div>
<div data-lazy-load-target="frame">
</div>
</div>
</details>
<% else %>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-warning rounded-full"></div>
<p class="text-sm text-secondary"><%= t("providers.snaptrade.status_needs_registration") %></p>
</div>
<% end %>
<% else %>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
<p class="text-sm text-secondary"><%= t("providers.snaptrade.status_not_configured") %></p>
<% if items&.any? %>
<% item = items.first %>
<% unless item.user_registered? %>
<div class="flex items-center gap-2 border-t border-primary pt-4 mt-4">
<span class="w-2 h-2 bg-warning rounded-full"></span>
<p class="text-sm text-secondary"><%= t("providers.snaptrade.status_needs_registration") %></p>
</div>
<% end %>
</div>
<% end %>
<% if items&.any? && items.first.user_registered? %>
<% item = items.first %>
<div class="border-t border-primary pt-4 mt-4">
<details class="group"
data-controller="lazy-load"
data-action="toggle->lazy-load#toggled"
data-lazy-load-url-value="<%= connections_snaptrade_item_path(item) %>"
data-lazy-load-auto-open-param-value="manage">
<summary class="flex items-center justify-between cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<div class="flex items-center gap-2">
<p class="text-sm text-secondary">
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
<% if item.unlinked_accounts_count > 0 %>
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
<% end %>
</p>
</div>
<span class="flex items-center gap-1 text-sm text-secondary hover:text-primary">
<%= t("providers.snaptrade.manage_connections") %>
<%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %>
</span>
</summary>
<div class="mt-3 space-y-3" data-lazy-load-target="content">
<div data-lazy-load-target="loading" class="flex items-center gap-2 text-sm text-secondary py-2">
<%= icon "loader-2", class: "w-4 h-4 animate-spin" %>
<%= t("providers.snaptrade.loading_connections") %>
</div>
<div data-lazy-load-target="frame">
</div>
</div>
</details>
</div>
<% end %>
</div>

View File

@@ -1,25 +1,14 @@
<div class="space-y-4">
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium"><%= t("sophtron_items.sophtron_panel.setup_instructions_title") %></p>
<ol>
<li><%= t("sophtron_items.sophtron_panel.setup_instructions.step_1_html", url: "https://www.sophtron.com") %></li>
<li><%= t("sophtron_items.sophtron_panel.setup_instructions.step_2") %></li>
<li><%= t("sophtron_items.sophtron_panel.setup_instructions.step_3") %></li>
</ol>
<p class="text-primary font-medium"><%= t("sophtron_items.sophtron_panel.field_descriptions_title") %></p>
<ul>
<li><%= t("sophtron_items.sophtron_panel.field_descriptions.user_id_html") %></li>
<li><%= t("sophtron_items.sophtron_panel.field_descriptions.access_key_html") %></li>
<li><%= t("sophtron_items.sophtron_panel.field_descriptions.base_url_html") %></li>
</ul>
</div>
<%= render "settings/providers/setup_steps",
steps: [
t("sophtron_items.sophtron_panel.setup_instructions.step_1_html", url: "https://www.sophtron.com").html_safe,
t("sophtron_items.sophtron_panel.setup_instructions.step_2"),
t("sophtron_items.sophtron_panel.setup_instructions.step_3")
] %>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<%= render DS::Alert.new(message: error_msg, variant: :error) %>
<% end %>
<%
@@ -56,13 +45,4 @@
</div>
<% end %>
<div class="flex items-center gap-2">
<% if Current.family.sophtron_items.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary"><%= t("sophtron_items.sophtron_panel.status.configured_html", accounts_path: accounts_path) %></p>
<% else %>
<div class="w-2 h-2 bg-muted rounded-full"></div>
<p class="text-sm text-secondary"><%= t("sophtron_items.sophtron_panel.status.not_configured") %></p>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,7 @@
<%# locals: (status:) %>
<% classes = status_pill_classes(status) %>
<% dot_class, pill_class = classes[:dot], classes[:pill] %>
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium <%= pill_class %>">
<span class="w-1.5 h-1.5 rounded-full flex-shrink-0 <%= dot_class %>"></span>
<%= t("settings.providers.status.#{status}") %>
</span>

View File

@@ -0,0 +1,15 @@
<%# locals: (provider_key:, last_synced_at: nil) %>
<% recently_synced = last_synced_at.present? && last_synced_at > 60.seconds.ago %>
<% button_label = recently_synced ? t("settings.providers.recently_synced") : t("settings.providers.sync_provider") %>
<%= render DS::Button.new(
variant: "icon",
size: "sm",
icon: "refresh-cw",
href: sync_provider_settings_providers_path(provider_key: provider_key),
method: :post,
disabled: recently_synced,
title: button_label,
aria: { label: button_label },
class: "disabled:opacity-40 disabled:cursor-not-allowed",
form: { onclick: "event.stopPropagation()", class: "inline-flex" }
) %>

View File

@@ -0,0 +1,21 @@
<%= render DS::Dialog.new(frame: "drawer", responsive: true, auto_open: true) do |dialog| %>
<% provider_key = @panel_key || @provider_configuration&.provider_key&.to_s %>
<% dialog.with_header(custom_header: true) do %>
<%= render "settings/providers/drawer_header", provider_key: provider_key, title: @panel_title %>
<% end %>
<% dialog.with_body do %>
<% if @panel_partial %>
<turbo-frame id="<%= @panel_key %>-connect-form" target="_top">
<%= render "settings/providers/#{@panel_partial}" %>
</turbo-frame>
<% else %>
<turbo-frame id="config-connect-form" target="_top">
<%= render "settings/providers/provider_form", configuration: @provider_configuration %>
</turbo-frame>
<% end %>
<p class="text-xs text-secondary mt-6 pt-3 border-t border-tertiary">
<%= t("settings.providers.drawer_trust_statement") %>
</p>
<% end %>
<% end %>

View File

@@ -1,94 +1,98 @@
<%= content_for :page_title, "Sync Providers" %>
<%= content_for :page_title, t("settings.providers.bank_sync.page_title") %>
<div class="space-y-4">
<% if @encryption_error %>
<div class="p-4 rounded-lg bg-destructive/10 border border-destructive/20">
<div class="flex items-start gap-3">
<%= icon("triangle-alert", class: "w-5 h-5 text-destructive shrink-0 mt-0.5") %>
<div>
<h3 class="font-medium text-primary"><%= t("settings.providers.encryption_error.title") %></h3>
<p class="text-secondary text-sm mt-1"><%= t("settings.providers.encryption_error.message") %></p>
<%= render DS::Alert.new(
variant: :error,
message: safe_join([
content_tag(:h2, t("settings.providers.encryption_error.title"), class: "font-medium"),
content_tag(:p, t("settings.providers.encryption_error.message"), class: "text-sm mt-1")
])
) %>
<% else %>
<div class="flex items-center justify-between gap-4">
<p class="text-secondary"><%= t("settings.providers.bank_sync.lede") %></p>
<% if @connected.any? || @needs_attention.any? %>
<% sync_all_disabled = Current.family.last_sync_all_attempted_at.present? && Current.family.last_sync_all_attempted_at > 30.seconds.ago %>
<%= render DS::Link.new(
text: t("settings.providers.sync_all"),
icon: "refresh-cw",
variant: "outline",
href: sync_all_settings_providers_path,
method: :post,
title: sync_all_disabled ? t("settings.providers.sync_all_recently") : nil,
aria: { disabled: sync_all_disabled.to_s },
class: sync_all_disabled ? "opacity-50 pointer-events-none" : nil
) %>
<% end %>
</div>
<% all_connections = @needs_attention + @connected %>
<% if all_connections.any? %>
<% if @health %>
<%= render "settings/providers/health_strip",
connected: @health[:connected],
needs_attention: @health[:needs_attention],
accounts_syncing: @health[:accounts_syncing],
last_synced_at: @health[:last_synced_at] %>
<% end %>
<%= render "settings/providers/group_heading",
title: t("settings.providers.groups.your_connections"),
count: all_connections.size %>
<div class="space-y-2">
<% all_connections.each do |entry| %>
<% auto_open = all_connections.size == 1 %>
<%= render "settings/providers/connection_row", entry: entry, open: auto_open %>
<% end %>
</div>
<% end %>
<% if @available.any? %>
<div data-controller="providers-filter">
<%= render "settings/providers/search_filters" %>
<div id="available" class="flex items-baseline justify-between gap-3 mt-6 mb-1.5 px-1">
<h2 class="text-xs font-medium text-secondary uppercase flex items-baseline gap-2">
<%= t("settings.providers.groups.available") %>
<span class="text-subdued font-normal normal-case tabular-nums">
· <span data-providers-filter-target="count"><%= @available.size %></span>
</span>
</h2>
</div>
<div data-providers-filter-target="empty" class="hidden text-sm text-secondary px-1 py-2 flex items-center gap-2">
<span><%= t("settings.providers.empty_filter") %></span>
<button type="button" data-action="click->providers-filter#clear" class="text-primary underline">
<%= t("settings.providers.clear_filter") %>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<% @available.each do |entry| %>
<% meta = Provider::Metadata.for(entry[:provider_key]) %>
<%= render Settings::ProviderCard.new(
provider_key: entry[:provider_key],
name: entry[:title],
tagline: t("settings.providers.taglines.#{entry[:provider_key]}", default: nil),
region: meta[:region],
kind: meta[:kind],
tier: meta[:tier],
maturity: meta[:maturity] || :stable,
logo_bg: meta[:logo_bg],
logo_text: meta[:logo_text]
) %>
<% end %>
</div>
</div>
</div>
<% else %>
<div>
<p class="text-secondary mb-4">
Configure credentials for third-party sync providers. Settings configured here will override environment variables.
</p>
</div>
<% end %>
<% unless @encryption_error %>
<% @provider_configurations.each do |config| %>
<%= settings_section title: config.provider_key.titleize, collapsible: true, open: false do %>
<%= render "settings/providers/provider_form", configuration: config %>
<% end %>
<% end %>
<%# Providers below are hardcoded because they manage Family-scoped connections %>
<%# (via their own models like SimplefinItem, LunchflowItem, etc.) rather than global settings. %>
<%# They require custom UI for connection management, status display, and sync actions. %>
<%# The controller excludes them from @provider_configurations (see prepare_show_context). %>
<%= settings_section title: "Lunch Flow", collapsible: true, open: false do %>
<turbo-frame id="lunchflow-providers-panel">
<%= render "settings/providers/lunchflow_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "SimpleFIN", collapsible: true, open: false do %>
<turbo-frame id="simplefin-providers-panel">
<%= render "settings/providers/simplefin_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %>
<turbo-frame id="enable_banking-providers-panel">
<%= render "settings/providers/enable_banking_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "CoinStats (beta)", collapsible: true, open: false do %>
<turbo-frame id="coinstats-providers-panel">
<%= render "settings/providers/coinstats_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Mercury (beta)", collapsible: true, open: false do %>
<turbo-frame id="mercury-providers-panel">
<%= render "settings/providers/mercury_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Coinbase (beta)", collapsible: true, open: false do %>
<turbo-frame id="coinbase-providers-panel">
<%= render "settings/providers/coinbase_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Binance (beta)", collapsible: true, open: false do %>
<turbo-frame id="binance-providers-panel">
<%= render "settings/providers/binance_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %>
<turbo-frame id="snaptrade-providers-panel">
<%= render "settings/providers/snaptrade_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Indexa Capital (alpha)", collapsible: true, open: false do %>
<turbo-frame id="indexa_capital-providers-panel">
<%= render "settings/providers/indexa_capital_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "Sophtron (alpha)", collapsible: true, open: false do %>
<turbo-frame id="sophtron-providers-panel">
<%= render "settings/providers/sophtron_panel" %>
</turbo-frame>
<% else %>
<%= render "settings/providers/group_heading",
title: t("settings.providers.groups.available"),
count: 0,
anchor: "available" %>
<p class="text-sm text-secondary px-1 py-2"><%= t("settings.providers.groups.empty_available") %></p>
<% end %>
<% end %>
</div>