Compare commits

..

1 Commits

Author SHA1 Message Date
Zach Gollwitzer
9a2a7b31d4 First sketch of budgeting module 2024-12-30 17:29:59 -05:00
577 changed files with 6301 additions and 10734 deletions

64
.ai/cursorrules.md Normal file
View File

@@ -0,0 +1,64 @@
<!-- Copy this file to .cursorrules in the root of the project on your local machine if you'd like to use these rules with Cursor. -->
You are an expert in Ruby, Ruby on Rails, Postgres, Tailwind, Stimulus, Hotwire and Turbo and always use the latest stable versions of those technologies.
**Code Style and Structure**
- Write concise, technical Ruby code with accurate examples.
- Prefer iteration and modularization over code duplication.
- Use descriptive variable names with auxiliary verbs (e.g., is_loading, has_error).
- Structure files: models, controllers, views, helpers, services, jobs, mailers.
**Naming Conventions**
- Use snake_case for file names and directories (e.g., app/models/user_profile.rb).
- Use CamelCase for classes and modules (e.g., UserProfile).
**Ruby on Rails Usage**
- Use Rails conventions for MVC structure.
- Favor scopes over class methods for queries.
- Use strong parameters for mass assignment protection.
- Use partials to DRY up views.
**Syntax and Formatting**
- Use two spaces for indentation.
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
- Use descriptive method names and keep methods short.
**Commenting Code**
- Write clear, concise comments to explain the purpose of individual functions and methods.
- Use comments to describe the intent and functionality of complex logic.
- Avoid redundant comments that state the obvious.
**UI and Styling**
- Use Tailwind CSS for styling.
- Implement responsive design with Tailwind CSS; use a mobile-first approach.
- Use Stimulus for JavaScript behavior.
- Use Turbo for asynchronous actions and updates.
**Performance Optimization**
- Use eager loading to avoid N+1 queries.
- Cache expensive queries and partials where appropriate.
- Use background jobs for long-running tasks.
- Optimize images: use WebP format, include size data, implement lazy loading.
**Database Querying & Data Model Creation**
- Use ActiveRecord for data querying and model creation.
- Favor database constraints and indexes for data integrity and performance.
- Use migrations to manage schema changes.
**Key Conventions**
- Follow Rails best practices for RESTful routing.
- Optimize for performance and security.
- Use environment variables for configuration.
- Write tests for models, controllers, and features.
**AI Guidelines**
- Follow the users requirements carefully & to the letter.
- Confirm, then write code!
- Suggest solutions that I didn't think about—anticipate my needs
- Focus on readability over being performant.
- Fully implement all requested functionality.
- Leave NO todos, placeholders or missing pieces.
- Don't say things like "additional logic can be added here" — instead, add the logic.
- Be concise. Minimize any other prose.
- Consider new technologies and contrarian ideas, not just the conventional wisdom
- If I ask for adjustments to code, do not repeat all of my code unnecessarily. Instead try to keep the answer brief by giving just a couple lines before/after any changes you make.

View File

@@ -1,84 +0,0 @@
---
description: This rule explains the project's tech stack and code conventions
globs: *
---
This rule serves as high-level documentation for how the Maybe codebase is structured.
## Rules for AI
- Use this file to understand how the codebase works
- Treat this rule/file as your "source of truth" when making code recommendations
- When creating migrations, always use `rails g migration` instead of creating the file yourself
## Project Tech Stack
- Web framework: Ruby on Rails
- Minitest + fixtures for testing
- Propshaft for asset pipeline
- Hotwire Turbo/Stimulus for SPA-like UI/UX
- TailwindCSS for styles
- Lucide Icons for icons
- Database: PostgreSQL
- Jobs: GoodJob
- External
- Payments: Stripe
- User bank data syncing: Plaid
- Market data: Synth (our custom API)
## Project conventions
These conventions should be used when writing code for Maybe.
### Convention 1: Minimize dependencies, vanilla Rails is plenty
Dependencies are a natural part of building software, but we aim to minimize them when possible to keep this open-source codebase easy to understand, maintain, and contribute to.
- Push Rails to its limits before adding new dependencies
- When a new dependency is added, there must be a strong technical or business reason to add it
- When adding dependencies, you should favor old and reliable over new and flashy
### Convention 2: Leverage POROs and concerns over "service objects"
This codebase adopts a "skinny controller, fat models" convention. Furthermore, we put almost _everything_ directly in the `app/models/` folder and avoid separate folders for business logic such as `app/services/`.
- Organize large pieces of business logic into Rails concerns and POROs (Plain ole' Ruby Objects)
- While a Rails concern _may_ offer shared functionality (i.e. "duck types"), it can also be a "one-off" concern that is only included in one place for better organization and readability.
- When concerns are used for code organization, they should be organized around the "traits" of a model; not for simply moving code to another spot in the codebase.
- When possible, models should answer questions about themselves—for example, we might have a method, `account.balance_series` that returns a time-series of the account's most recent balances. We prefer this over something more service-like such as `AccountSeries.new(account).call`.
### Convention 3: Prefer server-side solutions over client-side solutions
- When possible, leverage Turbo frames over complex, JS-driven client-side solutions
- When writing a client-side solution, use Stimulus controllers and keep it simple!
- Especially when dealing with money and currencies, calculate + format server-side and then pass that to the client to display
- Keep client-side code for where it truly shines. For example, [bulk_select_controller.js](mdc:app/javascript/controllers/bulk_select_controller.js) is a case where server-side solutions would degrade the user experience significantly. When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this.
### Convention 4: Sacrifice performance, optimize for simplicitly and clarity
This codebase is still young. We are still rapidly iterating on domain designs and features. Because of this, code should be optimized for simplicitly and clarity over performance.
- Focus on good OOP design first, performance second
- Be mindful of large performance bottlenecks, but don't sweat the small stuff
### Convention 5: Prefer semantic, native HTML features
The HTML spec has improved tremendously over the years and offers a ton of functionality out of the box. We prefer semantic, native HTML solutions over JS-based ones. A few examples of this include:
- Using the `dialog` element for modals
- Using `summary` / `details` elements for disclosures (or `popover` attribute)
The Hotwire suite (Turbo/Stimulus) works very well with these native elements and we optimize for this.
### Convention 6: Use Minitest + Fixtures for testing, minimize fixtures
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
- Always use Minitest and fixtures for testing.
- Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed.
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [balance_calculator_test.rb](mdc:test/models/account/balance_calculator_test.rb)
### Convention 7: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
- Enforce `null` checks, unique indexes, and other simple validations in the DB
- ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible.
- Complex validations and business logic should remain in ActiveRecord

View File

@@ -1,134 +0,0 @@
---
description: This rule explains the system architecture and data flow of the Rails app
globs: *
---
This file outlines how the codebase is structured and how data flows through the app.
This is a personal finance application built in Ruby on Rails. The primary domain entities for this app are outlined below. For an authoritative overview of the relationships, [schema.rb](mdc:db/schema.rb) is the source of truth.
## App Modes
The Maybe app runs in two distinct "modes", dictated by `Rails.application.config.app_mode`, which can be `managed` or `self_hosted`.
- "Managed" - in managed mode, the Maybe team operates and manages servers for users
- "Self Hosted" - in self hosted mode, users host the Maybe app on their own infrastructure, typically through Docker Compose. We have an example [docker-compose.example.yml](mdc:docker-compose.example.yml) file that runs [Dockerfile](mdc:Dockerfile) for this mode.
## Families and Users
- `Family` - all Stripe subscriptions, financial accounts, and the majority of preferences are stored at the [family.rb](mdc:app/models/family.rb) level.
- `User` - all [session.rb](mdc:app/models/session.rb) happen at the [user.rb](mdc:app/models/user.rb) level. A user belongs to a `Family` and can either be an `admin` or a `member`. Typically, a `Family` has a single admin, or "head of household" that manages finances while there will be several `member` users who can see the family's finances from varying perspectives.
## Currency Preference
Each `Family` selects a currency preference. This becomes the "main" currency in which all records are "normalized" to via [exchange_rate.rb](mdc:app/models/exchange_rate.rb) records so that the Maybe app can calculate metrics, historical graphs, and other insights in a single family currency.
## Accounts
The center of the app's domain is the [account.rb](mdc:app/models/account.rb). This represents a single financial account that has a `balance` and `currency`. For example, an `Account` could be "Chase Checking", which is a single financial account at Chase Bank. A user could have multiple accounts at a single institution (i.e. "Chase Checking", "Chase Credit Card", "Chase Savings") or an account could be a standalone account, such as "My Home" (a primary residence).
### Accountables
In the app, [account.rb](mdc:app/models/account.rb) is a Rails "delegated type" with the following subtypes (separate DB tables). Each account has a `classification` or either `asset` or `liability`. While the types are a flat hierarchy, below, they have been organized by their classification:
- Asset accountables
- [depository.rb](mdc:app/models/depository.rb) - a typical "bank account" such as a savings or checking account
- [investment.rb](mdc:app/models/investment.rb) - an account that has "holdings" such as a brokerage, 401k, etc.
- [crypto.rb](mdc:app/models/crypto.rb) - an account that tracks the value of one or more crypto holdings
- [property.rb](mdc:app/models/property.rb) - an account that tracks the value of a physical property such as a house or rental property
- [vehicle.rb](mdc:app/models/vehicle.rb) - an account that tracks the value of a vehicle
- [other_asset.rb](mdc:app/models/other_asset.rb) - an asset that cannot be classified by the other account types. For example, "jewelry".
- Liability accountables
- [credit_card.rb](mdc:app/models/credit_card.rb) - an account that tracks the debt owed on a credit card
- [loan.rb](mdc:app/models/loan.rb) - an account that tracks the debt owed on a loan (i.e. mortgage, student loan)
- [other_liability.rb](mdc:app/models/other_liability.rb) - a liability that cannot be classified by the other account types. For example, "IOU to a friend"
### Account Balances
An account [balance.rb](mdc:app/models/account/balance.rb) represents a single balance value for an account on a specific `date`. A series of balance records is generated daily for each account and is how we show a user's historical balance graph.
- For simple accounts like a "Checking Account", the balance represents the amount of cash in the account for a date.
- For a more complex account like "Investment Brokerage", the `balance` represents the combination of the "cash balance" + "holdings value". Each accountable type has different components that make up the "balance", but in all cases, the "balance" represents "How much the account is worth" (when `classification` is `asset`) or "How much is owed on the account" (when `classification` is `liability`)
All balances are calculated daily by [balance_calculator.rb](mdc:app/models/account/balance_calculator.rb).
### Account Holdings
An account [holding.rb](mdc:app/models/account/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`.
For investment accounts with holdings, [holding_calculator.rb](mdc:app/models/account/holding_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [balance_calculator.rb](mdc:app/models/account/balance_calculator.rb).
### Account Entries
An account [entry.rb](mdc:app/models/account/entry.rb) is also a Rails "delegated type". `Account::Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/account/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`.
The `amount` of an [entry.rb](mdc:app/models/account/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example:
- A negative amount for a credit card account represents a "payment" to that account, which _reduces_ its balance (since it is a `liability`)
- A negative amount for a checking account represents an "income" to that account, which _increases_ its balance (since it is an `asset`)
- A negative amount for an investment/brokerage trade represents a "sell" transaction, which _increases_ the cash balance of the account
There are 3 entry types, defined as [entryable.rb](mdc:app/models/account/entryable.rb) records:
- `Account::Valuation` - an account [valuation.rb](mdc:app/models/account/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Account::Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today.
- `Account::Transaction` - an account [transaction.rb](mdc:app/models/account/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense".
- `Account::Trade` - an account [trade.rb](mdc:app/models/account/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`.
### Account Transfers
A [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](mdc:app/models/account/transaction.rb) and an outflow [transaction.rb](mdc:app/models/account/transaction.rb). The Maybe system auto-matches transfers based on the following criteria:
- Must be from different accounts
- Must be within 4 days of each other
- Must be the same currency
- Must be opposite values
There are two primary forms of a transfer:
- Regular transfer - a normal movement of money between two accounts. For example, "Transfer $500 from Checking account to Brokerage account".
- Debt payment - a special form of transfer where the _receiver_ of funds is a [loan.rb](mdc:app/models/loan.rb) type account.
Regular transfers are typically _excluded_ from income and expense calculations while a debt payment is considered an "expense".
## Plaid Items
A [plaid_item.rb](mdc:app/models/plaid_item.rb) represents a "connection" maintained by our external data provider, Plaid in the "hosted" mode of the app. An "Item" has 1 or more [plaid_account.rb](mdc:app/models/plaid_account.rb) records, which are each associated 1:1 with an internal Maybe [account.rb](mdc:app/models/account.rb).
All relevant metadata about the item and its underlying accounts are stored on [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb), while the "normalized" data is then stored on internal Maybe domain models.
## "Syncs"
The Maybe app has the concept of a [syncable.rb](mdc:app/models/concerns/syncable.rb), which represents any model which can have its data "synced" in the background. "Syncables" include:
- `Account` - an account "sync" will sync account holdings, balances, and enhance transaction metadata
- `PlaidItem` - a Plaid Item "sync" fetches data from Plaid APIs, normalizes that data, stores it on internal Maybe models, and then finally performs an "Account sync" for each of the underlying accounts created from the Plaid Item.
- `Family` - a Family "sync" loops through the family's Plaid Items and individual Accounts and "syncs" each of them. A family is synced once per day, automatically through [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb).
Each "sync" creates a [sync.rb](mdc:app/models/sync.rb) record in the database, which keeps track of the status of the sync, any errors that it encounters, and acts as an "audit table" for synced data.
Below are brief descriptions of each type of sync in more detail.
### Account Syncs
The most important type of sync is the account sync. It is orchestrated by the account [syncer.rb](mdc:app/models/account/syncer.rb), and performs a few important tasks:
- Auto-matches transfer records for the account
- Calculates holdings and balances for the account
- Enriches transaction data
- Converts account balances that are not in the family's preferred currency to the preferred currency
An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated.
### Plaid Item Syncs
A Plaid Item sync is an ETL (extract, transform, load) operation:
1. [plaid_item.rb](mdc:app/models/plaid_item.rb) fetches data from the external Plaid API
2. [plaid_item.rb](mdc:app/models/plaid_item.rb) creates and loads this data to [plaid_account.rb](mdc:app/models/plaid_account.rb) records
3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/account/entry.rb), the internal Maybe representations of the data.
### Family Syncs
A family sync happens once daily via [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb). A family sync is an "orchestrator" of Account and Plaid Item syncs.

View File

@@ -1,13 +0,0 @@
---
description: This file describes Maybe's design system and how views should be styled
globs: app/views/**,app/helpers/**,app/javascript/controllers/**
---
Use this rule whenever you are writing html, css, or even styles in Stimulus controllers that use D3.js.
The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css)
- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives and tokens we use in the codebase
- Always generate semantic HTML
- Never create new styles in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so
- Always favor the "utility first" Tailwind approach. Reusable style classes should not be created often. Code should be reused primarily through ERB partials.
- Always prefer using the utility "tokens" defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) when possible. For example, use `text-primary` rather than `text-gray-900`.

View File

@@ -1,4 +1,4 @@
ARG RUBY_VERSION=3.4.1
ARG RUBY_VERSION=3.3.5
FROM ruby:${RUBY_VERSION}-slim-bullseye
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \

View File

@@ -118,6 +118,4 @@ STRIPE_WEBHOOK_SECRET=
#
PLAID_CLIENT_ID=
PLAID_SECRET=
PLAID_ENV=
PLAID_EU_CLIENT_ID=
PLAID_EU_SECRET=
PLAID_ENV=

View File

@@ -1,19 +1,12 @@
---
name: Bug report
about: Create a report to help us improve
title: 'Bug: [Add descriptive title here]'
labels: ''
title: 'Bug: '
labels: ":bug: Bug"
assignees: ''
---
**Where did this bug occur? (required)**
- [ ] I am a self-hosted user reporting a bug from my self hosted app
- [ ] I am a user of Maybe's paid app
_Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_
**Describe the bug**
A clear and concise description of what the bug is.
@@ -27,5 +20,14 @@ Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**What version of Maybe are you using?**
This could be "Hosted" (i.e. app.maybefinance.com) or "Self-hosted". If "Self-hosted", please include the version you're currently on.
**What operating system and browser are you using?**
The more info the better.
**Screenshots / Recordings**
If applicable, add screenshots or short video recordings to help show the bug in more detail.
**Additional context**
Add any other context about the problem here.

View File

@@ -22,8 +22,6 @@ jobs:
name: Build docker image
needs: [ ci ]
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
@@ -67,7 +65,7 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: 'linux/amd64,linux/arm64'
platforms: linux/amd64,linux/arm64,linux/arm/v7
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false

View File

@@ -1 +1 @@
3.4.1
3.3.5

View File

@@ -4,8 +4,6 @@ It means so much that you're interested in contributing to Maybe! Seriously. Tha
## House Rules
- Before contributing, familiarize yourself with our project conventions. You should read through our [Project Conventions Rule](https://github.com/maybe-finance/maybe/.cursor/rules/project-conventions.mdc), which is intended for LLMs, but is also an excellent primer on how we write code for Maybe.
- While totally optional, consider using Cursor + VSCode as it will automatically apply our project conventions to your code via the `.cursor/rules` directory.
- Before contributing, please check if it already exists in [issues](https://github.com/maybe-finance/maybe/issues) or [PRs](https://github.com/maybe-finance/maybe/pulls)
- Given the speed at which we're moving on the codebase, we don't assign issues or "give" issues to anyone.
- When multiple PRs are submitted for the same issue, we take the one that most succinctly & efficiently solves a given problem and stays within the scope of work.

View File

@@ -1,15 +1,15 @@
# syntax = docker/dockerfile:1
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.4.1
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
ARG RUBY_VERSION=3.3.5
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libvips postgresql-client git
apt-get install --no-install-recommends -y curl libvips postgresql-client
# Set production environment
ENV RAILS_ENV="production" \
@@ -17,29 +17,29 @@ ENV RAILS_ENV="production" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development"
# Throw-away build stage to reduce size of final image
FROM base AS build
FROM base as build
# Install packages needed to build gems
RUN apt-get install --no-install-recommends -y build-essential libpq-dev pkg-config
RUN apt-get install --no-install-recommends -y build-essential git libpq-dev pkg-config
# Install application gems
COPY .ruby-version Gemfile Gemfile.lock ./
RUN bundle install
RUN rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git
RUN bundle exec bootsnap precompile --gemfile -j 0
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
bundle exec bootsnap precompile --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile -j 0 app/ lib/
RUN bundle exec bootsnap precompile app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base

11
Gemfile
View File

@@ -21,22 +21,18 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails"
# Hotwire
gem "stimulus-rails"
gem "turbo-rails"
# Temporary pin to commit to fix crypto.randomUUID() errors. Revert this when the change has been released.
gem "hotwire_combobox", github: "josefarias/hotwire_combobox", ref: "b827048a8305e1115d5f96931ba1c9750d1e59fc"
gem "hotwire_combobox"
# Background Jobs
gem "good_job"
# Error logging
gem "stackprof"
gem "rack-mini-profiler"
gem "sentry-ruby"
gem "sentry-rails"
gem "logtail-rails"
# Active Storage
gem "aws-sdk-s3", "~> 1.177.0", require: false
gem "aws-sdk-s3", require: false
gem "image_processing", ">= 1.2"
# Other
@@ -55,8 +51,6 @@ gem "redcarpet"
gem "stripe"
gem "intercom-rails"
gem "plaid"
gem "rotp", "~> 6.3"
gem "rqrcode", "~> 2.2"
group :development, :test do
gem "debug", platforms: %i[mri windows]
@@ -73,7 +67,6 @@ group :development do
gem "ruby-lsp-rails"
gem "web-console"
gem "faker"
gem "benchmark-ips"
end
group :test do

View File

@@ -1,14 +1,3 @@
GIT
remote: https://github.com/josefarias/hotwire_combobox.git
revision: b827048a8305e1115d5f96931ba1c9750d1e59fc
ref: b827048a8305e1115d5f96931ba1c9750d1e59fc
specs:
hotwire_combobox (0.3.2)
platform_agent (>= 1.0.1)
rails (>= 7.0.7.2)
stimulus-rails (>= 1.2)
turbo-rails (>= 1.2)
GIT
remote: https://github.com/maybe-finance/lucide-rails.git
revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0
@@ -94,25 +83,24 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.1043.0)
aws-sdk-core (3.217.0)
aws-partitions (1.1023.0)
aws-sdk-core (3.214.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.97.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.177.0)
aws-sdk-s3 (1.176.1)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0)
aws-sigv4 (1.10.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.4.0)
benchmark-ips (2.14.0)
better_html (2.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
@@ -124,7 +112,7 @@ GEM
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (7.0.0)
brakeman (6.2.2)
racc
builder (3.3.0)
capybara (3.40.0)
@@ -136,12 +124,10 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0)
childprocess (5.0.0)
climate_control (1.2.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
rexml
@@ -151,13 +137,13 @@ GEM
debug (1.10.0)
irb (~> 1.10)
reline (>= 0.3.8)
docile (1.4.1)
docile (1.4.0)
dotenv (3.1.7)
dotenv-rails (3.1.7)
dotenv (= 3.1.7)
railties (>= 6.1)
drb (2.2.1)
erb_lint (0.9.0)
erb_lint (0.7.0)
activesupport
better_html (>= 2.0.1)
parser (>= 2.7.1.4)
@@ -179,34 +165,35 @@ GEM
net-http (>= 0.5.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.1-aarch64-linux-musl)
ffi (1.17.1-arm-linux-gnu)
ffi (1.17.1-arm-linux-musl)
ffi (1.17.1-arm64-darwin)
ffi (1.17.1-x86_64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
ffi (1.17.0-aarch64-linux-gnu)
ffi (1.17.0-arm-linux-gnu)
ffi (1.17.0-arm64-darwin)
ffi (1.17.0-x86-linux-gnu)
ffi (1.17.0-x86_64-darwin)
ffi (1.17.0-x86_64-linux-gnu)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.9.0)
good_job (4.6.0)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
fugit (>= 1.11.0)
railties (>= 6.1.0)
thor (>= 1.0.0)
hashdiff (1.1.2)
highline (3.1.2)
reline
hashdiff (1.1.1)
highline (3.0.1)
hotwire-livereload (2.0.0)
actioncable (>= 7.0.0)
listen (>= 3.0.0)
railties (>= 7.0.0)
i18n (1.14.7)
hotwire_combobox (0.3.2)
rails (>= 7.0.7.2)
stimulus-rails (>= 1.2)
turbo-rails (>= 1.2)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.14)
activesupport (>= 4.0.2)
@@ -218,8 +205,8 @@ GEM
rails-i18n
rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.1.0)
actionpack (>= 6.0.0)
@@ -228,41 +215,28 @@ GEM
inline_svg (1.10.0)
activesupport (>= 3.0)
nokogiri (>= 1.6)
intercom-rails (1.0.6)
intercom-rails (1.0.5)
activesupport (> 4.0)
jwt (~> 2.0)
io-console (0.8.0)
irb (1.15.1)
pp (>= 0.6.0)
irb (1.14.3)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.10.1)
jwt (2.10.1)
json (2.9.0)
jwt (2.9.3)
base64
language_server-protocol (3.17.0.4)
launchy (3.1.0)
language_server-protocol (3.17.0.3)
launchy (3.0.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.6)
logtail (0.1.15)
msgpack (~> 1.0)
logtail-rack (0.2.6)
logtail (~> 0.1)
rack (>= 1.2, < 4.0)
logtail-rails (0.2.10)
actionpack (>= 5.0.0)
activerecord (>= 5.0.0)
logtail (~> 0.1, >= 0.1.14)
logtail-rack (~> 0.1)
railties (>= 5.0.0)
loofah (2.24.0)
logger (1.6.4)
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -272,18 +246,17 @@ GEM
net-smtp
marcel (1.0.4)
matrix (0.4.2)
mini_magick (5.1.2)
benchmark
logger
mini_magick (4.13.2)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.4)
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
msgpack (1.7.2)
multipart-post (2.4.1)
net-http (0.6.0)
uri
net-imap (0.5.5)
net-imap (0.5.1)
date
net-protocol
net-pop (0.1.2)
@@ -293,59 +266,47 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.2-aarch64-linux-gnu)
nokogiri (1.18.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.2-aarch64-linux-musl)
nokogiri (1.18.1-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.2-arm-linux-gnu)
nokogiri (1.18.1-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.2-arm-linux-musl)
nokogiri (1.18.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.2-arm64-darwin)
nokogiri (1.18.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-linux-musl)
nokogiri (1.18.1-x86_64-linux-gnu)
racc (~> 1.4)
octokit (9.2.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (9.3.3)
parallel (1.26.3)
parser (3.3.7.0)
parser (3.3.5.0)
ast (~> 2.4.1)
racc
pg (1.5.9)
plaid (36.1.0)
plaid (34.0.0)
faraday (>= 1.0.1, < 3.0)
faraday-multipart (>= 1.0.1, < 2.0)
platform_agent (1.0.1)
activesupport (>= 5.2.0)
useragent (~> 0.16.3)
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
prism (1.3.0)
prism (1.2.0)
propshaft (1.1.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
railties (>= 7.0.0)
psych (5.2.3)
psych (5.2.2)
date
stringio
public_suffix (6.0.1)
puma (6.6.0)
puma (6.5.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.10)
rack-mini-profiler (3.3.1)
rack (>= 1.2.0)
rack-session (2.1.0)
base64 (>= 0.1.0)
rack (3.1.8)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
@@ -372,7 +333,7 @@ GEM
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
rails-i18n (7.0.9)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
rails-settings-cached (2.9.6)
@@ -391,102 +352,96 @@ GEM
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
rbs (3.8.1)
rbs (3.6.1)
logger
rdoc (6.12.0)
rdoc (6.10.0)
psych (>= 4.0.0)
redcarpet (3.6.0)
regexp_parser (2.10.0)
regexp_parser (2.9.2)
reline (0.6.0)
io-console (~> 0.5)
rexml (3.4.1)
rotp (6.3.0)
rqrcode (2.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rubocop (1.71.0)
rexml (3.3.9)
rubocop (1.67.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.36.2, < 2.0)
regexp_parser (>= 2.4, < 3.0)
rubocop-ast (>= 1.32.2, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.0)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.32.3)
parser (>= 3.3.1.0)
rubocop-minitest (0.36.0)
rubocop-minitest (0.35.0)
rubocop (>= 1.61, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-performance (1.23.1)
rubocop-performance (1.21.0)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.29.1)
rubocop-rails (2.25.0)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails-omakase (1.0.0)
rubocop
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.23.9)
ruby-lsp (0.22.1)
language_server-protocol (~> 3.17.0)
prism (>= 1.2, < 2.0)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.4.0)
ruby-lsp (>= 0.23.0, < 0.24.0)
ruby-lsp-rails (0.3.27)
ruby-lsp (>= 0.22.0, < 0.23.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.3)
ruby-vips (2.2.2)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
rubyzip (2.3.2)
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.4.1)
selenium-webdriver (4.29.1)
selenium-webdriver (4.27.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.22.4)
sentry-rails (5.22.1)
railties (>= 5.0)
sentry-ruby (~> 5.22.4)
sentry-ruby (5.22.4)
sentry-ruby (~> 5.22.1)
sentry-ruby (5.22.1)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1)
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11813)
stackprof (0.2.27)
sorbet-runtime (0.5.11663)
stackprof (0.2.26)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.3)
stripe (13.4.1)
tailwindcss-rails (4.0.0)
stringio (3.1.2)
stripe (13.3.0)
tailwindcss-rails (3.0.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.0.6)
tailwindcss-ruby (4.0.6-aarch64-linux-gnu)
tailwindcss-ruby (4.0.6-aarch64-linux-musl)
tailwindcss-ruby (4.0.6-arm64-darwin)
tailwindcss-ruby (4.0.6-x86_64-darwin)
tailwindcss-ruby (4.0.6-x86_64-linux-gnu)
tailwindcss-ruby (4.0.6-x86_64-linux-musl)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
tailwindcss-ruby
tailwindcss-ruby (3.4.14)
tailwindcss-ruby (3.4.14-aarch64-linux)
tailwindcss-ruby (3.4.14-arm-linux)
tailwindcss-ruby (3.4.14-arm64-darwin)
tailwindcss-ruby (3.4.14-x86_64-darwin)
tailwindcss-ruby (3.4.14-x86_64-linux)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
thor (1.3.2)
timeout (0.4.3)
turbo-rails (2.0.11)
@@ -494,9 +449,7 @@ GEM
railties (>= 6.0.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
unicode-display_width (2.6.0)
uri (1.0.2)
useragent (0.16.11)
vcr (6.3.1)
@@ -506,13 +459,12 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.25.0)
webmock (3.24.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
websocket-driver (0.7.7)
base64
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
@@ -521,21 +473,15 @@ GEM
PLATFORMS
aarch64-linux
aarch64-linux-gnu
aarch64-linux-musl
arm-linux
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86-linux
x86_64-darwin
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
aws-sdk-s3 (~> 1.177.0)
aws-sdk-s3
bcrypt (~> 3.1)
benchmark-ips
bootsnap
brakeman
capybara
@@ -550,7 +496,7 @@ DEPENDENCIES
faraday-retry
good_job
hotwire-livereload
hotwire_combobox!
hotwire_combobox
i18n-tasks
image_processing (>= 1.2)
importmap-rails
@@ -558,7 +504,6 @@ DEPENDENCIES
intercom-rails
jwt
letter_opener
logtail-rails
lucide-rails!
mocha
octokit
@@ -567,12 +512,9 @@ DEPENDENCIES
plaid
propshaft
puma (>= 5.0)
rack-mini-profiler
rails (~> 7.2.2)
rails-settings-cached
redcarpet
rotp (~> 6.3)
rqrcode (~> 2.2)
rubocop-rails-omakase
ruby-lsp-rails
selenium-webdriver
@@ -590,7 +532,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.4.1p0
ruby 3.3.5p100
BUNDLED WITH
2.6.3
2.5.22

View File

@@ -33,15 +33,6 @@ There are 3 primary ways to use the Maybe app:
2. [One-click deploy](docs/hosting/one-click-deploy.md)
3. [Self-host with Docker](docs/hosting/docker.md)
## Contributing
Before contributing, you'll likely find it helpful
to [understand context and general vision/direction](https://github.com/maybe-finance/maybe/wiki).
Once you've done that, please visit
our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md)
to get started!
## Local Development Setup
**If you are trying to _self-host_ the Maybe app, stop here. You
@@ -116,6 +107,15 @@ In development, we use `letter_opener` to automatically open emails in your
browser. When an email sends locally, a new browser tab will open with a
preview.
## Contributing
Before contributing, you'll likely find it helpful
to [understand context and general vision/direction](https://github.com/maybe-finance/maybe/wiki).
Once you've done that, please visit
our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md)
to get started!
## Repo Activity
![Repo Activity](https://repobeats.axiom.co/api/embed/7866c9790deba0baf63ca1688b209130b306ea4e.svg "Repobeats analytics image")

View File

@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none">
<path d="M8.39804 24.0315H4.09584C3.07641 24.0315 2.25 24.8609 2.25 25.8841C2.25 26.9072 3.07641 27.7367 4.09584 27.7367H8.39804C9.41747 27.7367 10.2439 26.9072 10.2439 25.8841C10.2439 24.8609 9.41747 24.0315 8.39804 24.0315Z" fill="#F23E94"/>
<path d="M27.6403 27.7359H31.9425C32.9619 27.7359 33.7883 26.9065 33.7883 25.8833C33.7883 24.8601 32.9619 24.0307 31.9425 24.0307H27.6403C26.6209 24.0307 25.7945 24.8601 25.7945 25.8833C25.7945 26.9065 26.6209 27.7359 27.6403 27.7359Z" fill="#F23E94"/>
<path d="M19.7588 24.0189H16.2567C15.2373 24.0189 14.4109 24.8483 14.4109 25.8715C14.4109 26.8947 15.2373 27.7241 16.2567 27.7241H19.7588C20.7783 27.7241 21.6047 26.8947 21.6047 25.8715C21.6047 24.8483 20.7783 24.0189 19.7588 24.0189Z" fill="#F23E94"/>
<path d="M25.9683 22.4047H30.1112C31.1306 22.4047 31.957 21.5753 31.957 20.5521C31.957 19.529 31.1306 18.6995 30.1112 18.6995H25.9683C24.9489 18.6995 24.1225 19.529 24.1225 20.5521C24.1225 21.5753 24.9489 22.4047 25.9683 22.4047Z" fill="#6927DA"/>
<path d="M9.99971 18.6993H5.85685C4.83742 18.6993 4.01101 19.5288 4.01101 20.5519C4.01101 21.5751 4.83742 22.4045 5.85685 22.4045H9.99971C11.0191 22.4045 11.8455 21.5751 11.8455 20.5519C11.8455 19.5288 11.0191 18.6993 9.99971 18.6993Z" fill="#6927DA"/>
<path d="M21.0888 18.6875H14.924C13.9045 18.6875 13.0781 19.517 13.0781 20.5401C13.0781 21.5633 13.9045 22.3927 14.924 22.3927H21.0888C22.1082 22.3927 22.9346 21.5633 22.9346 20.5401C22.9346 19.517 22.1082 18.6875 21.0888 18.6875Z" fill="#6927DA"/>
<path d="M15.5578 13.2072H7.69136C6.67193 13.2072 5.84552 14.0366 5.84552 15.0598C5.84552 16.0829 6.67193 16.9123 7.69136 16.9123H15.5578C16.5772 16.9123 17.4036 16.0829 17.4036 15.0598C17.4036 14.0366 16.5772 13.2072 15.5578 13.2072Z" fill="#1570EF"/>
<path d="M20.9094 16.9116H28.2735C29.2929 16.9116 30.1193 16.0821 30.1193 15.059C30.1193 14.0358 29.2929 13.2064 28.2735 13.2064L20.9094 13.2064C19.89 13.2064 19.0636 14.0358 19.0636 15.059C19.0636 16.0821 19.89 16.9116 20.9094 16.9116Z" fill="#1570EF"/>
<path d="M26.5036 7.875H22.3515C21.3321 7.875 20.5057 8.70443 20.5057 9.72759C20.5057 10.7507 21.3321 11.5802 22.3515 11.5802H26.5036C27.523 11.5802 28.3494 10.7507 28.3494 9.72759C28.3494 8.70443 27.523 7.875 26.5036 7.875Z" fill="#22CCEE"/>
<path d="M13.6077 7.875H9.45557C8.43614 7.875 7.60973 8.70443 7.60973 9.72759C7.60973 10.7507 8.43614 11.5802 9.45557 11.5802H13.6077C14.6271 11.5802 15.4535 10.7507 15.4535 9.72759C15.4535 8.70443 14.6271 7.875 13.6077 7.875Z" fill="#22CCEE"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,181 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Reset rules, default styles applied to plain HTML */
@layer base {
details>summary::-webkit-details-marker {
@apply hidden;
}
details>summary {
@apply list-none;
}
}
@layer components {
.form-field {
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
}
.form-field__label, .hw-combobox__label {
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
}
.form-field__input {
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
@apply focus:opacity-100 focus:outline-none focus:ring-0;
@apply placeholder-shown:opacity-50;
@apply disabled:text-gray-400;
}
.form-field__radio {
@apply text-gray-900;
}
.form-field__submit {
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
}
input:checked+label+.toggle-switch-dot {
transform: translateX(100%);
}
[type='checkbox'].maybe-checkbox {
@apply rounded-sm;
}
[type='checkbox'].maybe-checkbox--light {
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
}
[type='checkbox'].maybe-checkbox--dark {
@apply ring-gray-900 checked:text-white;
}
[type='checkbox'].maybe-checkbox--dark:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
select[multiple="multiple"] {
@apply py-2 pr-2 space-y-0.5;
}
select[multiple="multiple"] option {
@apply py-2 rounded-md;
}
select[multiple="multiple"] option:checked {
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
}
select[multiple="multiple"] option:active,
select[multiple="multiple"] option:focus {
@apply bg-white;
}
.maybe-switch {
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
}
.prose--github-release-notes {
.octicon {
@apply inline-block overflow-visible align-text-bottom fill-current;
}
.dropdown-caret {
@apply content-none border-4 border-b-0 border-transparent border-t-gray-500 size-0 inline-block;
}
.user-mention {
@apply font-bold;
}
}
.tooltip {
@apply hidden absolute;
}
.btn {
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500;
}
.btn--primary {
@apply bg-gray-900 text-white hover:bg-gray-700 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
}
.btn--secondary {
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
}
.btn--outline {
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
}
.btn--ghost {
@apply border border-transparent text-gray-900 hover:bg-gray-50;
}
}
.combobox {
.hw-combobox__main__wrapper, .hw-combobox__input {
@apply w-full;
}
.hw-combobox__main__wrapper {
@apply border-0 p-0 focus:border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none focus-within:shadow-none;
}
.hw-combobox__listbox {
@apply absolute top-[160%] right-0 w-full bg-transparent rounded z-30;
}
.hw_combobox__pagination__wrapper {
@apply h-px;
&:only-child {
@apply bg-transparent;
}
}
--hw-border-color: rgba(0, 0, 0, 0.2);
--hw-handle-width: 20px;
--hw-handle-height: 20px;
--hw-handle-offset-right: 0px;
}
/* Small, single purpose classes that should take precedence over other styles */
@layer utilities {
.scrollbar::-webkit-scrollbar {
width: 4px;
}
.scrollbar::-webkit-scrollbar-thumb {
background: #d6d6d6;
border-radius: 10px;
}
.scrollbar::-webkit-scrollbar-thumb:hover {
background: #a6a6a6;
}
}
/* Custom scrollbar implementation for Windows browsers */
.windows {
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-thumb {
background: #d6d6d6;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #a6a6a6;
}
}

View File

@@ -1,115 +0,0 @@
@import 'tailwindcss';
@import "./maybe-design-system.css";
@import "./geist-font.css";
@import "./geist-mono-font.css";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
.combobox {
.hw-combobox__main__wrapper,
.hw-combobox__input {
@apply w-full;
}
.hw-combobox__main__wrapper {
@apply border-0 p-0 focus:border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none focus-within:shadow-none;
}
.hw-combobox__listbox {
@apply absolute top-[160%] right-0 w-full bg-transparent rounded z-30;
}
.hw-combobox__label {
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
}
.hw_combobox__pagination__wrapper {
@apply h-px;
&:only-child {
@apply bg-transparent;
}
}
--hw-border-color: rgba(0, 0, 0, 0.2);
--hw-handle-width: 20px;
--hw-handle-height: 20px;
--hw-handle-offset-right: 0px;
}
/* Typography */
.prose {
@apply max-w-none;
h2 {
@apply text-xl font-medium;
}
h3 {
@apply text-lg font-medium;
}
li {
@apply m-0;
}
details {
@apply mb-4 rounded-xl mt-3.5;
}
summary {
@apply flex items-center gap-1;
}
video {
@apply m-0 rounded-b-xl;
}
}
.prose--github-release-notes {
.octicon {
@apply inline-block overflow-visible align-text-bottom fill-current;
}
.dropdown-caret {
@apply content-none border-4 border-b-0 border-transparent border-t-gray-500 size-0 inline-block;
}
.user-mention {
@apply font-bold;
}
}
/* Custom scrollbar implementation for Windows browsers */
.windows {
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-thumb {
background: #d6d6d6;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #a6a6a6;
}
}
.scrollbar {
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: #d6d6d6;
border-radius: 10px;
}
&::-webkit-scrollbar-thumb:hover {
background: #a6a6a6;
}
}

View File

@@ -1,85 +0,0 @@
/* Variable font */
@font-face {
font-family: 'Geist';
src: url('./geist/Geist[wght].woff2') format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
/* Static fonts (fallback) */
@supports not (font-variation-settings: normal) {
@font-face {
font-family: 'Geist';
src: url('./geist/Geist-Thin.woff2') format('woff2');
font-weight: 100;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('./geist/Geist-ExtraLight.woff2') format('woff2');
font-weight: 200;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('./geist/Geist-Light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('./geist/Geist-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('./geist/Geist-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('./geist/Geist-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('./geist/Geist-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('./geist/Geist-ExtraBold.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('./geist/Geist-Black.woff2') format('woff2');
font-weight: 900;
font-style: normal;
font-display: swap;
}
}

View File

@@ -1,83 +0,0 @@
/* Variable font */
@font-face {
font-family: 'Geist Mono';
src: url('./geist_mono/GeistMono[wght].woff2') format('woff2-variations');
font-weight: 100 950;
font-style: normal;
font-display: swap;
}
/* Static fonts (fallback) */
@supports not (font-variation-settings: normal) {
@font-face {
font-family: 'Geist Mono';
src: url('./geist_mono/GeistMono-Thin.woff2') format('woff2');
font-weight: 100;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('./geist_mono/GeistMono-UltraLight.woff2') format('woff2');
font-weight: 200;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('./geist_mono/GeistMono-Light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('./geist_mono/GeistMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('./geist_mono/GeistMono-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('./geist_mono/GeistMono-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('./geist_mono/GeistMono-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('./geist_mono/GeistMono-Black.woff2') format('woff2');
font-weight: 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('./geist_mono/GeistMono-UltraBlack.woff2') format('woff2');
font-weight: 950;
font-style: normal;
font-display: swap;
}
}

View File

@@ -1,451 +0,0 @@
/*
This file contains all of the Figma design tokens, components, etc. that
are used globally across the app.
One-off styling (3rd party overrides, etc.) should be done in the application.css file.
*/
@theme {
/* Font families */
--font-sans: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
/* Base colors */
--color-white: #ffffff;
--color-black: #0B0B0B;
--color-success: var(--color-green-600);
--color-warning: var(--color-yellow-600);
--color-destructive: var(--color-red-600);
/* Gray scale */
--color-gray-25: #FAFAFA;
--color-gray-50: #F7F7F7;
--color-gray-100: #F0F0F0;
--color-gray-200: #E7E7E7;
--color-gray-300: #CFCFCF;
--color-gray-400: #9E9E9E;
--color-gray-500: #737373;
--color-gray-600: #5C5C5C;
--color-gray-700: #363636;
--color-gray-800: #242424;
--color-gray-900: #171717;
--color-gray: var(--color-gray-500);
--color-gray-tint-5: --alpha(var(--color-gray-500) / 5%);
--color-gray-tint-10: --alpha(var(--color-gray-500) / 10%);
/* Alpha colors */
--color-alpha-white-25: --alpha(var(--color-white) / 3%);
--color-alpha-white-50: --alpha(var(--color-white) / 5%);
--color-alpha-white-100: --alpha(var(--color-white) / 8%);
--color-alpha-white-200: --alpha(var(--color-white) / 10%);
--color-alpha-white-300: --alpha(var(--color-white) / 15%);
--color-alpha-white-400: --alpha(var(--color-white) / 20%);
--color-alpha-white-500: --alpha(var(--color-white) / 30%);
--color-alpha-white-600: --alpha(var(--color-white) / 40%);
--color-alpha-white-700: --alpha(var(--color-white) / 50%);
--color-alpha-white-800: --alpha(var(--color-white) / 70%);
--color-alpha-white-900: --alpha(var(--color-white) / 85%);
--color-alpha-black-25: --alpha(var(--color-black) / 3%);
--color-alpha-black-50: --alpha(var(--color-black) / 5%);
--color-alpha-black-100: --alpha(var(--color-black) / 8%);
--color-alpha-black-200: --alpha(var(--color-black) / 10%);
--color-alpha-black-300: --alpha(var(--color-black) / 15%);
--color-alpha-black-400: --alpha(var(--color-black) / 20%);
--color-alpha-black-500: --alpha(var(--color-black) / 30%);
--color-alpha-black-600: --alpha(var(--color-black) / 40%);
--color-alpha-black-700: --alpha(var(--color-black) / 50%);
--color-alpha-black-800: --alpha(var(--color-black) / 70%);
--color-alpha-black-900: --alpha(var(--color-black) / 85%);
/* Red scale */
--color-red-25: #FFFBFB;
--color-red-50: #FFF1F0;
--color-red-100: #FFDEDB;
--color-red-200: #FEB9B3;
--color-red-300: #F88C86;
--color-red-400: #ED4E4E;
--color-red-500: #F13636;
--color-red-600: #EC2222;
--color-red-700: #C91313;
--color-red-800: #A40E0E;
--color-red-900: #7E0707;
--color-red-tint-5: --alpha(var(--color-red-500) / 5%);
--color-red-tint-10: --alpha(var(--color-red-500) / 10%);
/* Green scale */
--color-green-25: #F6FEF9;
--color-green-50: #ECFDF3;
--color-green-100: #D1FADF;
--color-green-200: #A6F4C5;
--color-green-300: #6CE9A6;
--color-green-400: #32D583;
--color-green-500: #12B76A;
--color-green-600: #10A861;
--color-green-700: #078C52;
--color-green-800: #05603A;
--color-green-900: #054F31;
--color-green-tint-5: --alpha(var(--color-green-500) / 5%);
--color-green-tint-10: --alpha(var(--color-green-500) / 10%);
/* Yellow scale */
--color-yellow-25: #FFFCF5;
--color-yellow-50: #FFFAEB;
--color-yellow-100: #FEF0C7;
--color-yellow-200: #FEDF89;
--color-yellow-300: #FEC84B;
--color-yellow-400: #FDB022;
--color-yellow-500: #F79009;
--color-yellow-600: #DC6803;
--color-yellow-700: #B54708;
--color-yellow-800: #93370D;
--color-yellow-900: #7A2E0E;
--color-yellow-tint-5: --alpha(var(--color-yellow-500) / 5%);
--color-yellow-tint-10: --alpha(var(--color-yellow-500) / 10%);
/* Cyan scale */
--color-cyan-25: #F5FEFF;
--color-cyan-50: #ECFDFF;
--color-cyan-100: #CFF9FE;
--color-cyan-200: #A5F0FC;
--color-cyan-300: #67E3F9;
--color-cyan-400: #22CCEE;
--color-cyan-500: #06AED4;
--color-cyan-600: #088AB2;
--color-cyan-700: #0E7090;
--color-cyan-800: #155B75;
--color-cyan-900: #155B75;
--color-cyan-tint-5: --alpha(var(--color-cyan-500) / 5%);
--color-cyan-tint-10: --alpha(var(--color-cyan-500) / 10%);
/* Blue scale */
--color-blue-25: #F5FAFF;
--color-blue-50: #EFF8FF;
--color-blue-100: #D1E9FF;
--color-blue-200: #B2DDFF;
--color-blue-300: #84CAFF;
--color-blue-400: #53B1FD;
--color-blue-500: #2E90FA;
--color-blue-600: #1570EF;
--color-blue-700: #175CD3;
--color-blue-800: #1849A9;
--color-blue-900: #194185;
--color-blue-tint-5: --alpha(var(--color-blue-500) / 5%);
--color-blue-tint-10: --alpha(var(--color-blue-500) / 10%);
/* Indigo scale */
--color-indigo-25: #F5F8FF;
--color-indigo-50: #EFF4FF;
--color-indigo-100: #E0EAFF;
--color-indigo-200: #C7D7FE;
--color-indigo-300: #A4BCFD;
--color-indigo-400: #8098F9;
--color-indigo-500: #6172F3;
--color-indigo-600: #444CE7;
--color-indigo-700: #3538CD;
--color-indigo-800: #2D31A6;
--color-indigo-900: #2D3282;
--color-indigo-tint-5: --alpha(var(--color-indigo-500) / 5%);
--color-indigo-tint-10: --alpha(var(--color-indigo-500) / 10%);
/* Violet scale */
--color-violet-25: #FBFAFF;
--color-violet-50: #F5F3FF;
--color-violet-100: #ECE9FE;
--color-violet-200: #DDD6FE;
--color-violet-300: #C3B5FD;
--color-violet-400: #A48AFB;
--color-violet-500: #875BF7;
--color-violet-600: #7839EE;
--color-violet-700: #6927DA;
--color-violet-tint-5: --alpha(var(--color-violet-500) / 5%);
--color-violet-tint-10: --alpha(var(--color-violet-500) / 10%);
/* Fuchsia scale */
--color-fuchsia-25: #FEFAFF;
--color-fuchsia-50: #FDF4FF;
--color-fuchsia-100: #FBE8FF;
--color-fuchsia-200: #F6D0FE;
--color-fuchsia-300: #EEAAFD;
--color-fuchsia-400: #E478FA;
--color-fuchsia-500: #D444F1;
--color-fuchsia-600: #BA24D5;
--color-fuchsia-700: #9F1AB1;
--color-fuchsia-800: #821890;
--color-fuchsia-900: #6F1877;
--color-fuchsia-tint-5: --alpha(var(--color-fuchsia-500) / 5%);
--color-fuchsia-tint-10: --alpha(var(--color-fuchsia-500) / 10%);
/* Pink scale */
--color-pink-25: #FFFAFC;
--color-pink-50: #FEF0F7;
--color-pink-100: #FFD1E2;
--color-pink-200: #FFB1CE;
--color-pink-300: #FD8FBA;
--color-pink-400: #F86BA7;
--color-pink-500: #F23E94;
--color-pink-600: #D5327F;
--color-pink-700: #BA256B;
--color-pink-800: #9E1958;
--color-pink-900: #840B45;
--color-pink-tint-5: --alpha(var(--color-pink-500) / 5%);
--color-pink-tint-10: --alpha(var(--color-pink-500) / 10%);
/* Orange scale */
--color-orange-25: #FFF9F5;
--color-orange-50: #FFF4ED;
--color-orange-100: #FFE6D5;
--color-orange-200: #FFD6AE;
--color-orange-300: #FF9C66;
--color-orange-400: #FF692E;
--color-orange-500: #FF4405;
--color-orange-600: #E62E05;
--color-orange-700: #BC1B06;
--color-orange-800: #97180C;
--color-orange-900: #771A0D;
--color-orange-tint-5: --alpha(var(--color-orange-500) / 5%);
--color-orange-tint-10: --alpha(var(--color-orange-500) / 10%);
/* Border radius overrides */
--border-radius-md: 8px;
--border-radius-lg: 10px;
--shadow-xs: 0px 1px 2px 0px --alpha(var(--color-black) / 6%);
--shadow-sm: 0px 1px 6px 0px --alpha(var(--color-black) / 6%);
--shadow-md: 0px 4px 8px -2px --alpha(var(--color-black) / 6%);
--shadow-lg: 0px 12px 16px -4px --alpha(var(--color-black) / 6%);
--shadow-xl: 0px 20px 24px -4px --alpha(var(--color-black) / 6%);
}
/* Custom shadow borders used for surfaces / containers */
@utility shadow-border-xs {
box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50);
}
@utility shadow-border-sm {
box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50);
}
@utility shadow-border-md {
box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50);
}
@utility shadow-border-lg {
box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50);
}
@utility shadow-border-xl {
box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50);
}
/* Design system color utilities */
@utility text-primary {
@apply text-gray-900;
}
@utility text-secondary {
@apply text-gray-500;
}
@utility text-subdued {
@apply text-gray-400;
}
@utility text-link {
@apply text-blue-600;
}
@utility bg-surface {
@apply bg-gray-50;
}
@utility bg-surface-hover {
@apply bg-gray-100;
}
@utility bg-surface-inset {
@apply bg-gray-100;
}
@utility bg-surface-inset-hover {
@apply bg-gray-200;
}
@utility bg-container {
@apply bg-white;
}
@utility bg-container-hover {
@apply bg-gray-50;
}
@utility bg-container-inset {
@apply bg-gray-50;
}
@utility bg-container-inset-hover {
@apply bg-gray-100;
}
@utility bg-inverse {
@apply bg-gray-800;
}
@utility bg-inverse-hover {
@apply bg-gray-700;
}
@utility bg-overlay {
@apply bg-alpha-black-200;
}
@utility border-primary {
@apply border-alpha-black-300;
}
@utility border-secondary {
@apply border-alpha-black-200;
}
@utility border-tertiary {
@apply border-alpha-black-100;
}
@utility border-subdued {
@apply border-alpha-black-50;
}
@layer base {
form>button {
@apply cursor-pointer;
}
hr {
@apply text-gray-200;
}
details>summary::-webkit-details-marker {
@apply hidden;
}
details>summary {
@apply list-none;
}
select[multiple="multiple"] {
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
}
select[multiple="multiple"] option {
@apply py-2 rounded-md;
}
select[multiple="multiple"] option:checked {
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
}
select[multiple="multiple"] option:active,
select[multiple="multiple"] option:focus {
@apply bg-white;
}
}
@layer components {
/* Buttons */
.btn {
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500;
}
.btn--primary {
@apply bg-gray-900 text-white hover:bg-gray-700 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
}
.btn--secondary {
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
}
.btn--outline {
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
}
.btn--ghost {
@apply border border-transparent text-gray-900 hover:bg-gray-100;
}
.btn--destructive {
@apply bg-red-500 text-white hover:bg-red-600 disabled:bg-red-50 disabled:hover:bg-red-50 disabled:text-red-400;
}
/* Forms */
.form-field {
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
}
.form-field__label {
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
}
.form-field__input {
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
@apply placeholder-shown:opacity-50;
@apply disabled:text-gray-400;
@apply text-ellipsis overflow-hidden whitespace-nowrap;
&select {
@apply pr-8;
}
}
.form-field__radio {
@apply text-gray-900;
}
.form-field__submit {
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
}
/* Checkboxes */
.checkbox {
&[type='checkbox'] {
@apply rounded-sm;
}
}
.checkbox--light {
&[type='checkbox'] {
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
}
&[type='checkbox']:disabled {
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
}
}
.checkbox--dark {
&[type='checkbox'] {
@apply ring-gray-900 checked:text-white;
}
&[type='checkbox']:disabled {
@apply cursor-not-allowed opacity-80 ring-gray-600;
}
&[type='checkbox']:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
}
/* Switches */
.switch {
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
}
/* Tooltips */
.tooltip {
@apply hidden absolute;
}
}

View File

@@ -0,0 +1,26 @@
class Account::EntriesController < ApplicationController
layout :with_sidebar
before_action :set_account
def index
@q = search_params
@pagy, @entries = pagy(entries_scope.search(@q).reverse_chronological, limit: params[:per_page] || "10")
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def entries_scope
scope = Current.family.entries
scope = scope.where(account: @account) if @account
scope
end
def search_params
params.fetch(:q, {})
.permit(:search)
end
end

View File

@@ -1,4 +1,6 @@
class Account::HoldingsController < ApplicationController
layout :with_sidebar
before_action :set_holding, only: %i[show destroy]
def index

View File

@@ -21,6 +21,27 @@ class Account::TransactionsController < ApplicationController
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
end
def mark_transfers
selected_entries = Current.family.entries.account_transactions.where(id: bulk_update_params[:entry_ids])
TransferMatcher.new(Current.family).match!(selected_entries)
redirect_back_or_to transactions_url, notice: t(".success")
end
def unmark_transfers
Current.family
.entries
.account_transactions
.includes(:entryable)
.where(id: bulk_update_params[:entry_ids])
.each do |entry|
entry.entryable.update!(category_id: nil)
end
redirect_back_or_to transactions_url, notice: t(".success")
end
private
def bulk_delete_params
params.require(:bulk_delete).permit(entry_ids: [])

View File

@@ -1,56 +0,0 @@
class Account::TransferMatchesController < ApplicationController
before_action :set_entry
def new
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
@transfer_match_candidates = @entry.transfer_match_candidates
end
def create
@transfer = build_transfer
@transfer.save!
@transfer.sync_account_later
redirect_back_or_to transactions_path, notice: t(".success")
end
private
def set_entry
@entry = Current.family.entries.find(params[:transaction_id])
end
def transfer_match_params
params.require(:transfer_match).permit(:method, :matched_entry_id, :target_account_id)
end
def build_transfer
if transfer_match_params[:method] == "new"
target_account = Current.family.accounts.find(transfer_match_params[:target_account_id])
missing_transaction = Account::Transaction.new(
entry: target_account.entries.build(
amount: @entry.amount * -1,
currency: @entry.currency,
date: @entry.date,
name: "Transfer to #{@entry.amount.negative? ? @entry.account.name : target_account.name}",
)
)
transfer = Transfer.find_or_initialize_by(
inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.account_transaction,
outflow_transaction: @entry.amount.positive? ? @entry.account_transaction : missing_transaction
)
transfer.status = "confirmed"
transfer
else
target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id])
transfer = Transfer.find_or_initialize_by(
inflow_transaction: @entry.amount.negative? ? @entry.account_transaction : target_transaction.account_transaction,
outflow_transaction: @entry.amount.negative? ? target_transaction.account_transaction : @entry.account_transaction
)
transfer.status = "confirmed"
transfer
end
end
end

View File

@@ -0,0 +1,61 @@
class Account::TransfersController < ApplicationController
layout :with_sidebar
before_action :set_transfer, only: %i[destroy show update]
def new
@transfer = Account::Transfer.new
end
def show
end
def create
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
@transfer = Account::Transfer.build_from_accounts from_account, to_account, \
date: transfer_params[:date],
amount: transfer_params[:amount].to_d
if @transfer.save
@transfer.entries.each(&:sync_account_later)
redirect_to transactions_path, notice: t(".success")
else
# TODO: this is not an ideal way to handle errors and should eventually be improved.
# See: https://github.com/hotwired/turbo-rails/pull/367
flash[:alert] = @transfer.errors.full_messages.to_sentence
redirect_to transactions_path
end
end
def update
@transfer.update_entries!(transfer_update_params)
redirect_back_or_to transactions_url, notice: t(".success")
end
def destroy
@transfer.destroy!
redirect_back_or_to transactions_url, notice: t(".success")
end
private
def set_transfer
record = Account::Transfer.find(params[:id])
unless record.entries.all? { |entry| Current.family.accounts.include?(entry.account) }
raise ActiveRecord::RecordNotFound
end
@transfer = record
end
def transfer_params
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
end
def transfer_update_params
params.require(:account_transfer).permit(:excluded, :notes)
end
end

View File

@@ -1,25 +0,0 @@
class AccountableSparklinesController < ApplicationController
def show
@accountable = Accountable.from_type(params[:accountable_type]&.classify)
@series = Rails.cache.fetch(cache_key) do
family.accounts.active
.where(accountable_type: @accountable.name)
.balance_series(
currency: family.currency,
favorable_direction: @accountable.favorable_direction
)
end
render layout: false
end
private
def family
Current.family
end
def cache_key
family.build_cache_key("#{@accountable.name}_sparkline")
end
end

View File

@@ -1,11 +1,26 @@
class AccountsController < ApplicationController
before_action :set_account, only: %i[sync chart sparkline]
layout :with_sidebar
before_action :set_account, only: %i[sync]
def index
@manual_accounts = family.accounts.manual.alphabetically
@plaid_items = family.plaid_items.ordered
@manual_accounts = Current.family.accounts.where(scheduled_for_deletion: false).manual.alphabetically
@plaid_items = Current.family.plaid_items.where(scheduled_for_deletion: false).ordered
end
render layout: "settings"
def summary
@period = Period.from_param(params[:period])
snapshot = Current.family.snapshot(@period)
@net_worth_series = snapshot[:net_worth_series]
@asset_series = snapshot[:asset_series]
@liability_series = snapshot[:liability_series]
@accounts = Current.family.accounts.active
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
end
def list
@period = Period.from_param(params[:period])
render layout: false
end
def sync
@@ -17,27 +32,20 @@ class AccountsController < ApplicationController
end
def chart
@account = Current.family.accounts.find(params[:id])
render layout: "application"
end
def sparkline
render layout: false
end
def sync_all
unless family.syncing?
family.sync_later
unless Current.family.syncing?
Current.family.sync_later
end
redirect_to accounts_path
end
private
def family
Current.family
end
def set_account
@account = family.accounts.find(params[:id])
@account = Current.family.accounts.find(params[:id])
end
end

View File

@@ -12,7 +12,6 @@ class ApplicationController < ActionController::Base
return false unless Current.session
return false if Current.family.subscribed?
return false if subscription_pending? || request.path == settings_billing_path
return false if Current.family.active_accounts_count <= 3
true
end
@@ -22,6 +21,12 @@ class ApplicationController < ActionController::Base
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
end
def with_sidebar
return "turbo_rails/frame" if turbo_frame_request?
"with_sidebar"
end
def detect_os
user_agent = request.user_agent
@os = case user_agent

View File

@@ -1,48 +0,0 @@
class BudgetCategoriesController < ApplicationController
before_action :set_budget
def index
@budget_categories = @budget.budget_categories.includes(:category)
render layout: "wizard"
end
def show
@recent_transactions = @budget.transactions
if params[:id] == BudgetCategory.uncategorized.id
@budget_category = @budget.uncategorized_budget_category
@recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil })
else
@budget_category = Current.family.budget_categories.find(params[:id])
@recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
.where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id)
end
@recent_transactions = @recent_transactions.order("account_entries.date DESC, ABS(account_entries.amount) DESC").take(3)
end
def update
@budget_category = Current.family.budget_categories.find(params[:id])
if @budget_category.update(budget_category_params)
respond_to do |format|
format.turbo_stream
format.html { redirect_to budget_budget_categories_path(@budget) }
end
else
render :index, status: :unprocessable_entity
end
end
private
def budget_category_params
params.require(:budget_category).permit(:budgeted_spending).tap do |params|
params[:budgeted_spending] = params[:budgeted_spending].presence || 0
end
end
def set_budget
start_date = Budget.param_to_date(params[:budget_month_year])
@budget = Current.family.budgets.find_by(start_date: start_date)
end
end

View File

@@ -1,46 +0,0 @@
class BudgetsController < ApplicationController
before_action :set_budget, only: %i[show edit update]
def index
redirect_to_current_month_budget
end
def show
end
def edit
render layout: "wizard"
end
def update
@budget.update!(budget_params)
redirect_to budget_budget_categories_path(@budget)
end
def picker
render partial: "budgets/picker", locals: {
family: Current.family,
year: params[:year].to_i || Date.current.year
}
end
private
def budget_create_params
params.require(:budget).permit(:start_date)
end
def budget_params
params.require(:budget).permit(:budgeted_spending, :expected_income)
end
def set_budget
start_date = Budget.param_to_date(params[:month_year])
@budget = Budget.find_or_bootstrap(Current.family, start_date: start_date)
raise ActiveRecord::RecordNotFound unless @budget
end
def redirect_to_current_month_budget
current_budget = Budget.find_or_bootstrap(Current.family, start_date: Date.current)
redirect_to budget_path(current_budget)
end
end

View File

@@ -1,17 +1,16 @@
class CategoriesController < ApplicationController
layout :with_sidebar
before_action :set_category, only: %i[edit update destroy]
before_action :set_categories, only: %i[update edit]
before_action :set_transaction, only: :create
def index
@categories = Current.family.categories.alphabetically
render layout: "settings"
end
def new
@category = Current.family.categories.new color: Category::COLORS.sample
set_categories
@categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id)
end
def create
@@ -20,34 +19,21 @@ class CategoriesController < ApplicationController
if @category.save
@transaction.update(category_id: @category.id) if @transaction
flash[:notice] = t(".success")
redirect_target_url = request.referer || categories_path
respond_to do |format|
format.html { redirect_back_or_to categories_path, notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
end
redirect_back_or_to categories_path, notice: t(".success")
else
set_categories
@categories = Current.family.categories.alphabetically.where(parent_id: nil)
render :new, status: :unprocessable_entity
end
end
def edit
@categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id)
end
def update
if @category.update(category_params)
flash[:notice] = t(".success")
@category.update! category_params
redirect_target_url = request.referer || categories_path
respond_to do |format|
format.html { redirect_back_or_to categories_path, notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
end
else
render :edit, status: :unprocessable_entity
end
redirect_back_or_to categories_path, notice: t(".success")
end
def destroy
@@ -67,14 +53,6 @@ class CategoriesController < ApplicationController
@category = Current.family.categories.find(params[:id])
end
def set_categories
@categories = unless @category.parent?
Current.family.categories.alphabetically.roots.where.not(id: @category.id)
else
[]
end
end
def set_transaction
if params[:transaction_id].present?
@transaction = Current.family.transactions.find(params[:transaction_id])
@@ -82,6 +60,6 @@ class CategoriesController < ApplicationController
end
def category_params
params.require(:category).permit(:name, :color, :parent_id, :classification, :lucide_icon)
params.require(:category).permit(:name, :color, :parent_id)
end
end

View File

@@ -1,4 +1,6 @@
class Category::DeletionsController < ApplicationController
layout :with_sidebar
before_action :set_category
before_action :set_replacement_category, only: :create

View File

@@ -2,8 +2,7 @@ module AccountableResource
extend ActiveSupport::Concern
included do
include ScrollFocusable
layout :with_sidebar
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
before_action :set_link_token, only: :new
end
@@ -23,12 +22,6 @@ module AccountableResource
end
def show
@q = params.fetch(:q, {}).permit(:search)
entries = @account.entries.search(@q).reverse_chronological
set_focused_record(entries, params[:focused_record_id])
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10", params: ->(params) { params.except(:focused_record_id) })
end
def edit
@@ -51,33 +44,18 @@ module AccountableResource
private
def set_link_token
@us_link_token = Current.family.get_link_token(
webhooks_url: plaid_us_webhooks_url,
@link_token = Current.family.get_link_token(
webhooks_url: webhooks_url,
redirect_url: accounts_url,
accountable_type: accountable_type.name,
region: :us
accountable_type: accountable_type.name
)
if Current.family.eu?
@eu_link_token = Current.family.get_link_token(
webhooks_url: plaid_eu_webhooks_url,
redirect_url: accounts_url,
accountable_type: accountable_type.name,
region: :eu
)
end
end
def plaid_us_webhooks_url
def webhooks_url
return webhooks_plaid_url if Rails.env.production?
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid"
end
def plaid_eu_webhooks_url
return webhooks_plaid_eu_url if Rails.env.production?
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid_eu"
base_url = ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/"))
base_url + "/webhooks/plaid"
end
def accountable_type

View File

@@ -13,7 +13,9 @@ module AutoSync
def family_needs_auto_sync?
return false unless Current.family.present?
return false unless Current.family.accounts.any?
(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
Current.family.last_synced_at.blank? ||
Current.family.last_synced_at.to_date < Date.current
end
end

View File

@@ -2,6 +2,7 @@ module EntryableResource
extend ActiveSupport::Concern
included do
layout :with_sidebar
before_action :set_entry, only: %i[show update destroy]
end
@@ -51,14 +52,11 @@ module EntryableResource
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") }
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace(
"header_account_entry_#{@entry.id}",
partial: "#{entryable_type.name.underscore.pluralize}/header",
locals: { entry: @entry }
),
turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry })
]
render turbo_stream: turbo_stream.replace(
"header_account_entry_#{@entry.id}",
partial: "#{entryable_type.name.underscore.pluralize}/header",
locals: { entry: @entry }
)
end
end
else

View File

@@ -1,21 +0,0 @@
module ScrollFocusable
extend ActiveSupport::Concern
def set_focused_record(record_scope, record_id, default_per_page: 10)
return unless record_id.present?
@focused_record = record_scope.find_by(id: record_id)
record_index = record_scope.pluck(:id).index(record_id)
return unless record_index
page_of_focused_record = (record_index / (params[:per_page]&.to_i || default_per_page)) + 1
if params[:page]&.to_i != page_of_focused_record
(
redirect_to(url_for(page: page_of_focused_record, focused_record_id: record_id))
)
end
end
end

View File

@@ -1,18 +0,0 @@
class EmailConfirmationsController < ApplicationController
skip_before_action :set_request_details, only: :new
skip_authentication only: :new
def new
# Returns nil if the token is invalid OR expired
@user = User.find_by_token_for(:email_confirmation, params[:token])
if @user&.unconfirmed_email && @user&.update(
email: @user.unconfirmed_email,
unconfirmed_email: nil
)
redirect_to new_session_path, notice: t(".success_login")
else
redirect_to root_path, alert: t(".invalid_token")
end
end
end

View File

@@ -0,0 +1,11 @@
class Help::ArticlesController < ApplicationController
layout :with_sidebar
def show
@article = Help::Article.find(params[:id])
unless @article
head :not_found
end
end
end

View File

@@ -29,7 +29,6 @@ class Import::ConfigurationsController < ApplicationController
:account_col_label,
:qty_col_label,
:ticker_col_label,
:exchange_operating_mic_col_label,
:price_col_label,
:entity_type_col_label,
:notes_col_label,

View File

@@ -1,5 +1,5 @@
class ImportsController < ApplicationController
before_action :set_import, only: %i[show publish destroy revert]
before_action :set_import, only: %i[show publish destroy]
def publish
@import.publish_later
@@ -10,7 +10,7 @@ class ImportsController < ApplicationController
def index
@imports = Current.family.imports
render layout: "settings"
render layout: with_sidebar
end
def new
@@ -31,11 +31,6 @@ class ImportsController < ApplicationController
end
end
def revert
@import.revert_later
redirect_to imports_path, notice: "Import is reverting in the background."
end
def destroy
@import.destroy

View File

@@ -34,24 +34,6 @@ class InvitationsController < ApplicationController
end
end
def destroy
unless Current.user.admin?
flash[:alert] = t("invitations.destroy.not_authorized")
redirect_to settings_profile_path
return
end
@invitation = Current.family.invitations.find(params[:id])
if @invitation.destroy
flash[:notice] = t("invitations.destroy.success")
else
flash[:alert] = t("invitations.destroy.failure")
end
redirect_to settings_profile_path
end
private
def invitation_params

View File

@@ -6,7 +6,6 @@ class InviteCodesController < ApplicationController
end
def create
raise StandardError, "You are not allowed to generate invite codes" unless Current.user.admin?
InviteCode.generate!
redirect_back_or_to invite_codes_path, notice: "Code generated"
end

View File

@@ -1,10 +1,10 @@
class MerchantsController < ApplicationController
layout :with_sidebar
before_action :set_merchant, only: %i[edit update destroy]
def index
@merchants = Current.family.merchants.alphabetically
render layout: "settings"
end
def new

View File

@@ -1,53 +0,0 @@
class MfaController < ApplicationController
layout :determine_layout
skip_authentication only: [ :verify, :verify_code ]
def new
redirect_to root_path if Current.user.otp_required?
Current.user.setup_mfa! unless Current.user.otp_secret.present?
end
def create
if Current.user.verify_otp?(params[:code])
Current.user.enable_mfa!
@backup_codes = Current.user.otp_backup_codes
render :backup_codes
else
Current.user.disable_mfa!
redirect_to new_mfa_path, alert: t(".invalid_code")
end
end
def verify
@user = User.find_by(id: session[:mfa_user_id])
redirect_to new_session_path unless @user
end
def verify_code
@user = User.find_by(id: session[:mfa_user_id])
if @user&.verify_otp?(params[:code])
session.delete(:mfa_user_id)
@session = create_session_for(@user)
redirect_to root_path
else
flash.now[:alert] = t(".invalid_code")
render :verify, status: :unprocessable_entity
end
end
def disable
Current.user.disable_mfa!
redirect_to settings_security_path, notice: t(".success")
end
private
def determine_layout
if action_name.in?(%w[verify verify_code])
"auth"
else
"settings"
end
end
end

View File

@@ -1,4 +1,5 @@
class OnboardingsController < ApplicationController
layout "application"
before_action :set_user
before_action :load_invitation

View File

@@ -1,20 +1,40 @@
class PagesController < ApplicationController
skip_before_action :authenticate_user!, only: %i[early_access]
layout :with_sidebar, except: %i[early_access]
def dashboard
@period = Period.from_key(params[:period], fallback: true)
@balance_sheet = Current.family.balance_sheet
@accounts = Current.family.accounts.active.with_attached_logo
@period = Period.from_param(params[:period])
snapshot = Current.family.snapshot(@period)
@net_worth_series = snapshot[:net_worth_series]
@asset_series = snapshot[:asset_series]
@liability_series = snapshot[:liability_series]
snapshot_transactions = Current.family.snapshot_transactions
@income_series = snapshot_transactions[:income_series]
@spending_series = snapshot_transactions[:spending_series]
@savings_rate_series = snapshot_transactions[:savings_rate_series]
snapshot_account_transactions = Current.family.snapshot_account_transactions
@top_spenders = snapshot_account_transactions[:top_spenders]
@top_earners = snapshot_account_transactions[:top_earners]
@top_savers = snapshot_account_transactions[:top_savers]
@accounts = Current.family.accounts.active
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological
# TODO: Placeholders for trendlines
placeholder_series_data = 10.times.map do |i|
{ date: Date.current - i.days, value: Money.new(0, Current.family.currency) }
end
@investing_series = TimeSeries.new(placeholder_series_data)
end
def changelog
@release_notes = Provider::Github.new.fetch_latest_release_notes
render layout: "settings"
end
def feedback
render layout: "settings"
end
def early_access

View File

@@ -5,7 +5,6 @@ class PlaidItemsController < ApplicationController
Current.family.plaid_items.create_from_public_token(
plaid_item_params[:public_token],
item_name: item_name,
region: plaid_item_params[:region]
)
redirect_to accounts_path, notice: t(".success")
@@ -21,10 +20,7 @@ class PlaidItemsController < ApplicationController
@plaid_item.sync_later
end
respond_to do |format|
format.html { redirect_to accounts_path }
format.json { head :ok }
end
redirect_to accounts_path
end
private
@@ -33,7 +29,7 @@ class PlaidItemsController < ApplicationController
end
def plaid_item_params
params.require(:plaid_item).permit(:public_token, :region, metadata: {})
params.require(:plaid_item).permit(:public_token, metadata: {})
end
def item_name

View File

@@ -5,7 +5,14 @@ class SecuritiesController < ApplicationController
@securities = Security.search({
search: query,
country: params[:country_code] == "US" ? "US" : nil
country: country_code_filter
})
end
private
def country_code_filter
filter = params[:country_code]
filter = "#{filter},US" unless filter == "US"
filter
end
end

View File

@@ -9,13 +9,8 @@ class SessionsController < ApplicationController
def create
if user = User.authenticate_by(email: params[:email], password: params[:password])
if user.otp_required?
session[:mfa_user_id] = user.id
redirect_to verify_mfa_path
else
@session = create_session_for(user)
redirect_to root_path
end
@session = create_session_for(user)
redirect_to root_path
else
flash.now[:alert] = t(".invalid_credentials")
render :new, status: :unprocessable_entity

View File

@@ -1,6 +1,4 @@
class Settings::BillingsController < ApplicationController
layout "settings"
class Settings::BillingsController < SettingsController
def show
@user = Current.user
end

View File

@@ -1,6 +1,4 @@
class Settings::HostingsController < ApplicationController
layout "settings"
class Settings::HostingsController < SettingsController
before_action :raise_if_not_self_hosted
def show
@@ -24,10 +22,6 @@ class Settings::HostingsController < ApplicationController
Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup]
end
if hosting_params.key?(:require_email_confirmation)
Setting.require_email_confirmation = hosting_params[:require_email_confirmation]
end
if hosting_params.key?(:synth_api_key)
Setting.synth_api_key = hosting_params[:synth_api_key]
end
@@ -40,7 +34,7 @@ class Settings::HostingsController < ApplicationController
private
def hosting_params
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key)
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :synth_api_key)
end
def raise_if_not_self_hosted

View File

@@ -1,6 +1,4 @@
class Settings::PreferencesController < ApplicationController
layout "settings"
class Settings::PreferencesController < SettingsController
def show
@user = Current.user
end

View File

@@ -1,33 +1,7 @@
class Settings::ProfilesController < ApplicationController
layout "settings"
class Settings::ProfilesController < SettingsController
def show
@user = Current.user
@users = Current.family.users.order(:created_at)
@pending_invitations = Current.family.invitations.pending
end
def destroy
unless Current.user.admin?
flash[:alert] = t("settings.profiles.destroy.not_authorized")
redirect_to settings_profile_path
return
end
@user = Current.family.users.find(params[:user_id])
if @user == Current.user
flash[:alert] = t("settings.profiles.destroy.cannot_remove_self")
redirect_to settings_profile_path
return
end
if @user.destroy
flash[:notice] = t("settings.profiles.destroy.member_removed")
else
flash[:alert] = t("settings.profiles.destroy.member_removal_failed")
end
redirect_to settings_profile_path
end
end

View File

@@ -1,6 +0,0 @@
class Settings::SecuritiesController < ApplicationController
layout "settings"
def show
end
end

View File

@@ -0,0 +1,3 @@
class SettingsController < ApplicationController
layout :with_sidebar
end

View File

@@ -1,4 +1,6 @@
class Tag::DeletionsController < ApplicationController
layout :with_sidebar
before_action :set_tag
before_action :set_replacement_tag, only: :create

View File

@@ -1,10 +1,10 @@
class TagsController < ApplicationController
layout :with_sidebar
before_action :set_tag, only: %i[edit update destroy]
def index
@tags = Current.family.tags.alphabetically
render layout: "settings"
end
def new

View File

@@ -1,102 +1,25 @@
class TransactionsController < ApplicationController
include ScrollFocusable
before_action :store_params!, only: :index
layout :with_sidebar
def index
@q = search_params
transactions_query = Current.family.transactions.active.search(@q)
search_query = Current.family.transactions.search(@q).includes(:entryable).reverse_chronological
@pagy, @transaction_entries = pagy(search_query, limit: params[:per_page] || "50")
set_focused_record(transactions_query, params[:focused_record_id], default_per_page: 50)
@pagy, @transactions = pagy(
transactions_query.includes(
{ entry: :account },
:category, :merchant, :tags,
transfer_as_outflow: { inflow_transaction: { entry: :account } },
transfer_as_inflow: { outflow_transaction: { entry: :account } }
).reverse_chronological,
limit: params[:per_page].presence || default_params[:per_page],
params: ->(params) { params.except(:focused_record_id) }
)
@totals = Current.family.income_statement.totals(transactions_scope: transactions_query)
end
def clear_filter
updated_params = {
"q" => search_params,
"page" => params[:page],
"per_page" => params[:per_page]
@totals = {
count: search_query.select { |t| t.currency == Current.family.currency }.count,
income: search_query.income_total(Current.family.currency).abs,
expense: search_query.expense_total(Current.family.currency)
}
q_params = updated_params["q"] || {}
param_key = params[:param_key]
param_value = params[:param_value]
if q_params[param_key].is_a?(Array)
q_params[param_key].delete(param_value)
q_params.delete(param_key) if q_params[param_key].empty?
else
q_params.delete(param_key)
end
updated_params["q"] = q_params.presence
Current.session.update!(prev_transaction_page_params: updated_params)
redirect_to transactions_path(updated_params)
end
private
def search_params
cleaned_params = params.fetch(:q, {})
params.fetch(:q, {})
.permit(
:start_date, :end_date, :search, :amount,
:amount_operator, accounts: [], account_ids: [],
categories: [], merchants: [], types: [], tags: []
)
.to_h
.compact_blank
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
cleaned_params
end
def store_params!
if should_restore_params?
params_to_restore = {}
params_to_restore[:q] = stored_params["q"].presence || default_params[:q]
params_to_restore[:page] = stored_params["page"].presence || default_params[:page]
params_to_restore[:per_page] = stored_params["per_page"].presence || default_params[:per_page]
redirect_to transactions_path(params_to_restore)
else
Current.session.update!(
prev_transaction_page_params: {
q: search_params,
page: params[:page],
per_page: params[:per_page]
}
)
end
end
def should_restore_params?
request.query_parameters.blank? && (stored_params["q"].present? || stored_params["page"].present? || stored_params["per_page"].present?)
end
def stored_params
Current.session.prev_transaction_page_params
end
def default_params
{
q: {},
page: 1,
per_page: 50
}
end
end

View File

@@ -1,72 +0,0 @@
class TransfersController < ApplicationController
before_action :set_transfer, only: %i[destroy show update]
def new
@transfer = Transfer.new
end
def show
@categories = Current.family.categories.expenses
end
def create
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
@transfer = Transfer.from_accounts(
from_account: from_account,
to_account: to_account,
date: transfer_params[:date],
amount: transfer_params[:amount].to_d
)
if @transfer.save
@transfer.sync_account_later
flash[:notice] = t(".success")
respond_to do |format|
format.html { redirect_back_or_to transactions_path }
redirect_target_url = request.referer || transactions_path
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
end
else
render :new, status: :unprocessable_entity
end
end
def update
if transfer_update_params[:status] == "rejected"
@transfer.reject!
elsif transfer_update_params[:status] == "confirmed"
@transfer.confirm!
end
@transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id])
respond_to do |format|
format.html { redirect_back_or_to transactions_url, notice: t(".success") }
format.turbo_stream
end
end
def destroy
@transfer.destroy!
redirect_back_or_to transactions_url, notice: t(".success")
end
private
def set_transfer
@transfer = Transfer.find(params[:id])
raise ActiveRecord::RecordNotFound unless @transfer.belongs_to_family?(Current.family)
end
def transfer_params
params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
end
def transfer_update_params
params.require(:transfer).permit(:notes, :status, :category_id)
end
end

View File

@@ -4,26 +4,10 @@ class UsersController < ApplicationController
def update
@user = Current.user
if email_changed?
if @user.initiate_email_change(user_params[:email])
if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation
handle_redirect(t(".success"))
else
redirect_to settings_profile_path, notice: t(".email_change_initiated")
end
else
error_message = @user.errors.any? ? @user.errors.full_messages.to_sentence : t(".email_change_failed")
redirect_to settings_profile_path, alert: error_message
end
else
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
@user.profile_image.purge if should_purge_profile_image?
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
@user.profile_image.purge if should_purge_profile_image?
respond_to do |format|
format.html { handle_redirect(t(".success")) }
format.json { head :ok }
end
end
handle_redirect(t(".success"))
end
def destroy
@@ -54,13 +38,9 @@ class UsersController < ApplicationController
user_params[:profile_image].blank?
end
def email_changed?
user_params[:email].present? && user_params[:email] != @user.email
end
def user_params
params.require(:user).permit(
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar,
:first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
)
end

View File

@@ -6,29 +6,11 @@ class WebhooksController < ApplicationController
webhook_body = request.body.read
plaid_verification_header = request.headers["Plaid-Verification"]
client = Provider::Plaid.new(Rails.application.config.plaid, region: :us)
client.validate_webhook!(plaid_verification_header, webhook_body)
client.process_webhook(webhook_body)
Provider::Plaid.validate_webhook!(plaid_verification_header, webhook_body)
Provider::Plaid.process_webhook(webhook_body)
render json: { received: true }, status: :ok
rescue => error
Sentry.capture_exception(error)
render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request
end
def plaid_eu
webhook_body = request.body.read
plaid_verification_header = request.headers["Plaid-Verification"]
client = Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu)
client.validate_webhook!(plaid_verification_header, webhook_body)
client.process_webhook(webhook_body)
render json: { received: true }, status: :ok
rescue => error
Sentry.capture_exception(error)
render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request
end
@@ -51,12 +33,10 @@ class WebhooksController < ApplicationController
Rails.logger.info "Unhandled event type: #{event.type}"
end
rescue JSON::ParserError => error
Sentry.capture_exception(error)
rescue JSON::ParserError
render json: { error: "Invalid payload" }, status: :bad_request
return
rescue Stripe::SignatureVerificationError => error
Sentry.capture_exception(error)
rescue Stripe::SignatureVerificationError
render json: { error: "Invalid signature" }, status: :bad_request
return
end

View File

@@ -1,22 +1,31 @@
module Account::EntriesHelper
def entries_by_date(entries, totals: false)
entries.group_by(&:date).map do |date, grouped_entries|
def permitted_entryable_partial_path(entry, relative_partial_path)
"account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}"
end
def unconfirmed_transfer?(entry)
entry.transfer.nil? && entry.entryable.category&.classification == "transfer"
end
def transfer_entries(entries)
transfers = entries.select { |e| e.transfer_id.present? }
transfers.map(&:transfer).uniq
end
def entries_by_date(entries, selectable: true, totals: false)
entries.reverse_chronological.group_by(&:date).map do |date, grouped_entries|
content = capture do
yield grouped_entries
end
next if content.blank?
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: }
end.compact.join.html_safe
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: }
end.join.html_safe
end
def entry_name_detailed(entry)
[
entry.date,
format_money(entry.amount_money),
entry.account.name,
entry.display_name
].join("")
end
private
def permitted_entryable_key(entry)
permitted_entryable_paths = %w[transaction valuation trade]
entry.entryable_name_short.presence_in(permitted_entryable_paths)
end
end

View File

@@ -0,0 +1,13 @@
module Account::HoldingsHelper
def brokerage_cash_holding(account)
currency = Money::Currency.new(account.currency)
account.holdings.build \
date: Date.current,
qty: account.cash_balance,
price: 1,
amount: account.cash_balance,
currency: currency.iso_code,
security: Security.new(ticker: currency.iso_code, name: currency.name)
end
end

View File

@@ -0,0 +1,2 @@
module Account::TransfersHelper
end

View File

@@ -1,6 +1,73 @@
module AccountsHelper
def period_label(period)
return "since account creation" if period.date_range.begin.nil?
start_date, end_date = period.date_range.first, period.date_range.last
return "Starting from #{start_date.strftime('%b %d, %Y')}" if end_date.nil?
return "Ending at #{end_date.strftime('%b %d, %Y')}" if start_date.nil?
days_apart = (end_date - start_date).to_i
case days_apart
when 1
"vs. yesterday"
when 7
"vs. last week"
when 30, 31
"vs. last month"
when 365, 366
"vs. last year"
else
"from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}"
end
end
def summary_card(title:, &block)
content = capture(&block)
render "accounts/summary_card", title: title, content: content
end
def to_accountable_title(accountable)
accountable.model_name.human
end
def accountable_text_class(accountable_type)
class_mapping(accountable_type)[:text]
end
def accountable_fill_class(accountable_type)
class_mapping(accountable_type)[:fill]
end
def accountable_bg_class(accountable_type)
class_mapping(accountable_type)[:bg]
end
def accountable_bg_transparent_class(accountable_type)
class_mapping(accountable_type)[:bg_transparent]
end
def accountable_color(accountable_type)
class_mapping(accountable_type)[:hex]
end
def account_groups(period: nil)
assets, liabilities = Current.family.accounts.active.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
[ assets.children.sort_by(&:name), liabilities.children.sort_by(&:name) ].flatten
end
private
def class_mapping(accountable_type)
{
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
end
end

View File

@@ -1,14 +1,18 @@
module ApplicationHelper
include Pagy::Frontend
def icon(key, size: "md", color: "current")
render partial: "shared/icon", locals: { key:, size:, color: }
end
# Convert alpha (0-1) to 8-digit hex (00-FF)
def hex_with_alpha(hex, alpha)
alpha_hex = (alpha * 255).round.to_s(16).rjust(2, "0")
"#{hex}#{alpha_hex}"
def date_format_options
[
[ "DD-MM-YYYY", "%d-%m-%Y" ],
[ "DD.MM.YYYY", "%d.%m.%Y" ],
[ "MM-DD-YYYY", "%m-%d-%Y" ],
[ "YYYY-MM-DD", "%Y-%m-%d" ],
[ "DD/MM/YYYY", "%d/%m/%Y" ],
[ "YYYY/MM/DD", "%Y/%m/%d" ],
[ "MM/DD/YYYY", "%m/%d/%Y" ],
[ "D/MM/YYYY", "%e/%m/%Y" ],
[ "YYYY.MM.DD", "%Y.%m.%d" ]
]
end
def title(page_title)
@@ -19,10 +23,6 @@ module ApplicationHelper
content_for(:header_title) { page_title }
end
def header_description(page_description)
content_for(:header_description) { page_description }
end
def family_notifications_stream
turbo_stream_from [ Current.family, :notifications ] if Current.family
end
@@ -67,13 +67,23 @@ module ApplicationHelper
render partial: "shared/drawer", locals: { content:, reload_on_close: }
end
def disclosure(title, default_open: true, &block)
def disclosure(title, &block)
content = capture &block
render partial: "shared/disclosure", locals: { title: title, content: content, open: default_open }
render partial: "shared/disclosure", locals: { title: title, content: content }
end
def page_active?(path)
current_page?(path) || (request.path.start_with?(path) && path != "/")
def sidebar_link_to(name, path, options = {})
is_current = current_page?(path) || (request.path.start_with?(path) && path != "/")
classes = [
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm font-medium text-gray-500",
(is_current ? "bg-white text-gray-900 shadow-xs border-alpha-black-50" : "hover:bg-gray-100 border-transparent")
].compact.join(" ")
link_to path, **options.merge(class: classes), aria: { current: ("page" if current_page?(path)) } do
concat(lucide_icon(options[:icon], class: "w-5 h-5")) if options[:icon]
concat(name)
end
end
def mixed_hex_styles(hex)
@@ -95,6 +105,24 @@ module ApplicationHelper
uri.relative? ? uri.path : root_path
end
def trend_styles(trend)
fallback = { bg_class: "bg-gray-500/5", text_class: "text-gray-500", symbol: "", icon: "minus" }
return fallback if trend.nil? || trend.direction.flat?
bg_class, text_class, symbol, icon = case trend.direction
when "up"
trend.favorable_direction.down? ? [ "bg-red-500/5", "text-red-500", "+", "arrow-up" ] : [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ]
when "down"
trend.favorable_direction.down? ? [ "bg-green-500/5", "text-green-500", "-", "arrow-down" ] : [ "bg-red-500/5", "text-red-500", "-", "arrow-down" ]
when "flat"
[ "bg-gray-500/5", "text-gray-500", "", "minus" ]
else
raise ArgumentError, "Invalid trend direction: #{trend.direction}"
end
{ bg_class: bg_class, text_class: text_class, symbol: symbol, icon: icon }
end
# Wrapper around I18n.l to support custom date formats
def format_date(object, format = :default, options = {})
date = object.to_date
@@ -111,12 +139,22 @@ module ApplicationHelper
def format_money(number_or_money, options = {})
return nil unless number_or_money
Money.new(number_or_money).format(options)
money = Money.new(number_or_money)
options.reverse_merge!(money.format_options(I18n.locale))
number_to_currency(money.amount, options)
end
def format_money_without_symbol(number_or_money, options = {})
return nil unless number_or_money
money = Money.new(number_or_money)
options.reverse_merge!(money.format_options(I18n.locale))
ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
end
def totals_by_currency(collection:, money_method:, separator: " | ", negate: false)
collection.group_by(&:currency)
.transform_values { |item| calculate_total(item, money_method, negate) }
.transform_values { |item| negate ? item.sum(&money_method) * -1 : item.sum(&money_method) }
.map { |_currency, money| format_money(money) }
.join(separator)
end
@@ -129,10 +167,23 @@ module ApplicationHelper
cookies[:admin] == "true"
end
private
def calculate_total(item, money_method, negate)
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }
total = items.sum(&money_method)
negate ? -total : total
def custom_pagy_url_for(pagy, page, current_path: nil)
if current_path.blank?
pagy_url_for(pagy, page)
else
uri = URI.parse(current_path)
params = URI.decode_www_form(uri.query || "").to_h
# Delete existing page param if it exists
params.delete("page")
# Add new page param unless it's page 1
params["page"] = page unless page == 1
if params.empty?
uri.path
else
"#{uri.path}?#{URI.encode_www_form(params)}"
end
end
end
end

View File

@@ -1,25 +1,11 @@
module CategoriesHelper
def transfer_category
def null_category
Category.new \
name: "Transfer",
color: Category::TRANSFER_COLOR,
lucide_icon: "arrow-right-left"
end
def payment_category
Category.new \
name: "Payment",
color: Category::PAYMENT_COLOR,
lucide_icon: "arrow-right"
end
def trade_category
Category.new \
name: "Trade",
color: Category::TRADE_COLOR
name: "Uncategorized",
color: Category::UNCATEGORIZED_COLOR
end
def family_categories
[ Category.uncategorized ].concat(Current.family.categories.alphabetically)
[ null_category ].concat(Current.family.categories.alphabetically)
end
end

View File

@@ -11,18 +11,16 @@ module FormsHelper
end
def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false)
form.label name, for: form.field_id(name, value), class: "group has-disabled:cursor-not-allowed" do
form.label name, for: form.field_id(name, value), class: "group has-[:disabled]:cursor-not-allowed" do
concat radio_tab_contents(label:, icon:)
concat form.radio_button(name, value, checked:, disabled:, class: "hidden")
end
end
def period_select(form:, selected:, classes: "border border-tertiary shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
periods_for_select = Period.all.map { |period| [ period.label_short, period.key ] }
form.select(:period, periods_for_select, { selected: selected.key }, class: classes, data: { "auto-submit-form-target": "auto" })
end
def period_select(form:, selected:, classes: "border border-alpha-black-100 shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-gray-900 focus:outline-none focus:ring-0")
periods_for_select = [ [ "7D", "last_7_days" ], [ "1M", "last_30_days" ], [ "1Y", "last_365_days" ] ]
form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" })
end
def currencies_for_select
Money::Currency.all_instances.sort_by { |currency| [ currency.priority, currency.name ] }
@@ -30,9 +28,9 @@ end
private
def radio_tab_contents(label:, icon:)
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-subdued group-has-checked:bg-white group-has-checked:text-gray-800 group-has-checked:shadow-sm") do
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm") do
concat lucide_icon(icon, class: "w-5 h-5")
concat tag.span(label, class: "group-has-checked:font-semibold")
concat tag.span(label, class: "group-has-[:checked]:font-semibold")
end
end
end

View File

@@ -0,0 +1,2 @@
module ImpersonationSessionsHelper
end

View File

@@ -20,7 +20,6 @@ module ImportsHelper
notes: "Notes",
qty: "Quantity",
ticker: "Ticker",
exchange: "Exchange",
price: "Price",
entity_type: "Type"
}[key]
@@ -46,7 +45,7 @@ module ImportsHelper
end
def cell_class(row, field)
base = "text-sm focus:ring-gray-900 focus:border-gray-900 w-full max-w-full disabled:text-subdued"
base = "text-sm focus:ring-gray-900 focus:border-gray-900 w-full max-w-full disabled:text-gray-400"
row.valid? # populate errors

View File

@@ -0,0 +1,2 @@
module InvitationsHelper
end

View File

@@ -365,11 +365,6 @@ module LanguagesHelper
end
def timezone_options
ActiveSupport::TimeZone.all
.sort_by { |tz| [ tz.utc_offset, tz.name ] }
.map do |tz|
name = tz.name.split(" - ").first.gsub(" (US & Canada)", "")
[ "(#{tz.formatted_offset}) #{name}", tz.tzinfo.identifier ]
end
ActiveSupport::TimeZone.all.map { |tz| [ tz.name + " (#{tz.tzinfo.identifier})", tz.tzinfo.identifier ] }
end
end

View File

@@ -7,8 +7,8 @@ module MenusHelper
end
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal)
link_to url, class: "flex items-center rounded-lg text-primary hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
link_to url, class: "flex items-center rounded-lg text-gray-900 hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-gray-500"))
concat(tag.span(label, class: "text-sm"))
end
end
@@ -26,7 +26,7 @@ module MenusHelper
private
def contextual_menu_icon
tag.button class: "flex hover:bg-gray-100 p-2 rounded cursor-pointer", data: { menu_target: "button" } do
lucide_icon "more-horizontal", class: "w-5 h-5 text-secondary"
lucide_icon "more-horizontal", class: "w-5 h-5 text-gray-500"
end
end

View File

@@ -1,20 +0,0 @@
module MfaHelper
def generate_mfa_qr_code(provisioning_uri)
qr_code = RQRCode::QRCode.new(provisioning_uri).as_svg(
color: "141414",
module_size: 4,
standalone: true,
use_path: true,
svg_attributes: {
width: "240",
height: "240",
viewBox: "0 0 65 65"
}
)
# Whitelist specific SVG attributes and elements that we know are safe
sanitize qr_code,
tags: %w[svg g path rect],
attributes: %w[viewBox height width fill stroke stroke-width d x y class]
end
end

View File

@@ -0,0 +1,2 @@
module PagesHelper
end

View File

@@ -1,9 +0,0 @@
module PlaidHelper
def plaid_webhooks_url(region = :us)
if Rails.env.production?
region.to_sym == :eu ? webhooks_plaid_eu_url : webhooks_plaid_url
else
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid#{region.to_sym == :eu ? '_eu' : ''}"
end
end
end

View File

@@ -0,0 +1,2 @@
module PropertiesHelper
end

View File

@@ -0,0 +1,2 @@
module SecuritiesHelper
end

View File

@@ -0,0 +1,2 @@
module Settings::BillingHelper
end

View File

@@ -0,0 +1,2 @@
module Settings::HostingHelper
end

Some files were not shown because too many files have changed in this diff Show More