mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-10 07:55:21 +02:00
Merge branch 'main' into multi-currency-transfer
This commit is contained in:
commit
9613bacabe
138 changed files with 8800 additions and 723 deletions
|
@ -66,54 +66,7 @@ All code should maximize readability and simplicity.
|
|||
- Example 1: be mindful of loading large data payloads in global layouts
|
||||
- Example 2: Avoid N+1 queries
|
||||
|
||||
### Convention 5: 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/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb)
|
||||
- Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence
|
||||
|
||||
#### Convention 5a: Write minimal, effective tests
|
||||
|
||||
- Use system tests sparingly as they increase the time to complete the test suite
|
||||
- Only write tests for critical and important code paths
|
||||
- Write tests as you go, when required
|
||||
- Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_.
|
||||
|
||||
Below are examples of necessary vs. unnecessary tests:
|
||||
|
||||
```rb
|
||||
# GOOD!!
|
||||
# Necessary test - in this case, we're testing critical domain business logic
|
||||
test "syncs balances" do
|
||||
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||
|
||||
@account.expects(:start_date).returns(2.days.ago.to_date)
|
||||
|
||||
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||
[
|
||||
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||
]
|
||||
)
|
||||
|
||||
assert_difference "@account.balances.count", 2 do
|
||||
Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||
end
|
||||
end
|
||||
|
||||
# BAD!!
|
||||
# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality
|
||||
test "saves balance" do
|
||||
balance_record = Balance.new(balance: 100, currency: "USD")
|
||||
|
||||
assert balance_record.save
|
||||
end
|
||||
```
|
||||
|
||||
### Convention 6: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
|
||||
### Convention 5: 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.
|
||||
|
|
64
.cursor/rules/stimulus_conventions.mdc
Normal file
64
.cursor/rules/stimulus_conventions.mdc
Normal file
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
This rule describes how to write Stimulus controllers.
|
||||
|
||||
- **Use declarative actions, not imperative event listeners**
|
||||
- Instead of assigning a Stimulus target and binding it to an event listener in the initializer, always write Controllers + ERB views declaratively by using Stimulus actions in ERB to call methods in the Stimulus JS controller. Below are good vs. bad code.
|
||||
|
||||
BAD code:
|
||||
|
||||
```js
|
||||
// BAD!!!! DO NOT DO THIS!!
|
||||
// Imperative - controller does all the work
|
||||
export default class extends Controller {
|
||||
static targets = ["button", "content"]
|
||||
|
||||
connect() {
|
||||
this.buttonTarget.addEventListener("click", this.toggle.bind(this))
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.contentTarget.classList.toggle("hidden")
|
||||
this.buttonTarget.textContent = this.contentTarget.classList.contains("hidden") ? "Show" : "Hide"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
GOOD code:
|
||||
|
||||
```erb
|
||||
<!-- Declarative - HTML declares what happens -->
|
||||
|
||||
<div data-controller="toggle">
|
||||
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
|
||||
<div data-toggle-target="content" class="hidden">Hello World!</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```js
|
||||
// Declarative - controller just responds
|
||||
export default class extends Controller {
|
||||
static targets = ["button", "content"]
|
||||
|
||||
toggle() {
|
||||
this.contentTarget.classList.toggle("hidden")
|
||||
this.buttonTarget.textContent = this.contentTarget.classList.contains("hidden") ? "Show" : "Hide"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Keep Stimulus controllers lightweight and simple**
|
||||
- Always aim for less than 7 controller targets. Any more is a sign of too much complexity.
|
||||
- Use private methods and expose a clear public API
|
||||
|
||||
- **Keep Stimulus controllers focused on what they do best**
|
||||
- Domain logic does NOT belong in a Stimulus controller
|
||||
- Stimulus controllers should aim for a single responsibility, or a group of highly related responsibilities
|
||||
- Make good use of Stimulus's callbacks, actions, targets, values, and classes
|
||||
|
||||
- **Component controllers should not be used outside the component**
|
||||
- If a Stimulus controller is in the app/components directory, it should only be used in its component view. It should not be used anywhere in app/views.
|
||||
|
87
.cursor/rules/testing.mdc
Normal file
87
.cursor/rules/testing.mdc
Normal file
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
description:
|
||||
globs: test/**
|
||||
alwaysApply: false
|
||||
---
|
||||
Use this rule to learn how to write tests for the Maybe codebase.
|
||||
|
||||
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
|
||||
|
||||
- **General testing rules**
|
||||
- Always use Minitest and fixtures for testing, NEVER rspec or factories
|
||||
- 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 to help create the records needed for the test, then inline the creation. For example, [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) provides helpers to easily do this.
|
||||
|
||||
- **Write minimal, effective tests**
|
||||
- Use system tests sparingly as they increase the time to complete the test suite
|
||||
- Only write tests for critical and important code paths
|
||||
- Write tests as you go, when required
|
||||
- Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_.
|
||||
|
||||
Below are examples of necessary vs. unnecessary tests:
|
||||
|
||||
```rb
|
||||
# GOOD!!
|
||||
# Necessary test - in this case, we're testing critical domain business logic
|
||||
test "syncs balances" do
|
||||
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||
|
||||
@account.expects(:start_date).returns(2.days.ago.to_date)
|
||||
|
||||
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||
[
|
||||
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||
]
|
||||
)
|
||||
|
||||
assert_difference "@account.balances.count", 2 do
|
||||
Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||
end
|
||||
end
|
||||
|
||||
# BAD!!
|
||||
# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality
|
||||
test "saves balance" do
|
||||
balance_record = Balance.new(balance: 100, currency: "USD")
|
||||
|
||||
assert balance_record.save
|
||||
end
|
||||
```
|
||||
|
||||
- **Test boundaries correctly**
|
||||
- Distinguish between commands and query methods. Test output of query methods; test that commands were called with the correct params. See an example below:
|
||||
|
||||
```rb
|
||||
class ExampleClass
|
||||
def do_something
|
||||
result = 2 + 2
|
||||
|
||||
CustomEventProcessor.process_result(result)
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
class ExampleClass < ActiveSupport::TestCase
|
||||
test "boundaries are tested correctly" do
|
||||
result = ExampleClass.new.do_something
|
||||
|
||||
# GOOD - we're only testing that the command was received, not internal implementation details
|
||||
# The actual tests for CustomEventProcessor belong in a different test suite!
|
||||
CustomEventProcessor.expects(:process_result).with(4).once
|
||||
|
||||
# GOOD - we're testing the implementation of ExampleClass inside its own test suite
|
||||
assert_equal 4, result
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
- Never test the implementation details of one class in another classes test suite
|
||||
|
||||
- **Stubs and mocks**
|
||||
- Use `mocha` gem
|
||||
- Always prefer `OpenStruct` when creating mock instances, or in complex cases, a mock class
|
||||
- Only mock what's necessary. If you're not testing return values, don't mock a return value.
|
||||
|
||||
|
100
.cursor/rules/view_conventions.mdc
Normal file
100
.cursor/rules/view_conventions.mdc
Normal file
|
@ -0,0 +1,100 @@
|
|||
---
|
||||
description:
|
||||
globs: app/views/**,app/javascript/**,app/components/**/*.js
|
||||
alwaysApply: false
|
||||
---
|
||||
Use this rule to learn how to write ERB views, partials, and Stimulus controllers should be incorporated into them.
|
||||
|
||||
- **Component vs. Partial Decision Making**
|
||||
- **Use ViewComponents when:**
|
||||
- Element has complex logic or styling patterns
|
||||
- Element will be reused across multiple views/contexts
|
||||
- Element needs structured styling with variants/sizes (like buttons, badges)
|
||||
- Element requires interactive behavior or Stimulus controllers
|
||||
- Element has configurable slots or complex APIs
|
||||
- Element needs accessibility features or ARIA support
|
||||
|
||||
- **Use Partials when:**
|
||||
- Element is primarily static HTML with minimal logic
|
||||
- Element is used in only one or few specific contexts
|
||||
- Element is simple template content (like CTAs, static sections)
|
||||
- Element doesn't need variants, sizes, or complex configuration
|
||||
- Element is more about content organization than reusable functionality
|
||||
|
||||
- **Prefer components over partials**
|
||||
- If there is a component available for the use case in app/components, use it
|
||||
- If there is no component, look for a partial
|
||||
- If there is no partial, decide between component or partial based on the criteria above
|
||||
|
||||
- **Examples of Component vs. Partial Usage**
|
||||
```erb
|
||||
<%# Component: Complex, reusable with variants and interactivity %>
|
||||
<%= render DialogComponent.new(variant: :drawer) do |dialog| %>
|
||||
<% dialog.with_header(title: "Account Settings") %>
|
||||
<% dialog.with_body { "Dialog content here" } %>
|
||||
<% end %>
|
||||
|
||||
<%# Component: Interactive with complex styling options %>
|
||||
<%= render ButtonComponent.new(text: "Save Changes", variant: "primary", confirm: "Are you sure?") %>
|
||||
|
||||
<%# Component: Reusable with variants %>
|
||||
<%= render FilledIconComponent.new(icon: "credit-card", variant: :surface) %>
|
||||
|
||||
<%# Partial: Static template content %>
|
||||
<%= render "shared/logo" %>
|
||||
|
||||
<%# Partial: Simple, context-specific content with basic styling %>
|
||||
<%= render "shared/trend_change", trend: @account.trend, comparison_label: "vs last month" %>
|
||||
|
||||
<%# Partial: Simple divider/utility %>
|
||||
<%= render "shared/ruler", classes: "my-4" %>
|
||||
|
||||
<%# Partial: Simple form utility %>
|
||||
<%= render "shared/form_errors", model: @account %>
|
||||
```
|
||||
|
||||
- **Keep domain logic out of the views**
|
||||
```erb
|
||||
<%# BAD!!! %>
|
||||
|
||||
<%# This belongs in the component file, not the template file! %>
|
||||
<% button_classes = { class: "bg-blue-500 hover:bg-blue-600" } %>
|
||||
|
||||
<%= tag.button class: button_classes do %>
|
||||
Save Account
|
||||
<% end %>
|
||||
|
||||
<%# GOOD! %>
|
||||
|
||||
<%= tag.button class: computed_button_classes do %>
|
||||
Save Account
|
||||
<% end %>
|
||||
```
|
||||
|
||||
- **Stimulus Integration in Views**
|
||||
- Always use the **declarative approach** when integrating Stimulus controllers
|
||||
- The ERB template should declare what happens, the Stimulus controller should respond
|
||||
- Refer to [stimulus_conventions.mdc](mdc:.cursor/rules/stimulus_conventions.mdc) to learn how to incorporate them into
|
||||
|
||||
GOOD Stimulus controller integration into views:
|
||||
|
||||
```erb
|
||||
<!-- Declarative - HTML declares what happens -->
|
||||
|
||||
<div data-controller="toggle">
|
||||
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
|
||||
<div data-toggle-target="content" class="hidden">Hello World!</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- **Stimulus Controller Placement Guidelines**
|
||||
- **Component controllers** (in `app/components/`) should only be used within their component templates
|
||||
- **Global controllers** (in `app/javascript/controllers/`) can be used across any view
|
||||
- Pass data from Rails to Stimulus using `data-*-value` attributes, not inline JavaScript
|
||||
- Use Stimulus targets to reference DOM elements, not manual `getElementById` calls
|
||||
|
||||
- **Naming Conventions**
|
||||
- **Components**: Use `ComponentName` suffix (e.g., `ButtonComponent`, `DialogComponent`, `FilledIconComponent`)
|
||||
- **Partials**: Use underscore prefix (e.g., `_trend_change.html.erb`, `_form_errors.html.erb`, `_sync_indicator.html.erb`)
|
||||
- **Shared partials**: Place in `app/views/shared/` directory for reusable content
|
||||
- **Context-specific partials**: Place in relevant controller view directory (e.g., `accounts/_account_sidebar_tabs.html.erb`)
|
|
@ -51,6 +51,14 @@ APP_DOMAIN=
|
|||
# Disable enforcing SSL connections
|
||||
# DISABLE_SSL=true
|
||||
|
||||
# Active Record Encryption Keys (Optional)
|
||||
# These keys are used to encrypt sensitive data like API keys in the database.
|
||||
# If not provided, they will be automatically generated based on your SECRET_KEY_BASE.
|
||||
# You can generate your own keys by running: rails db:encryption:init
|
||||
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
|
||||
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
|
||||
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
|
||||
|
||||
# ======================================================================================================
|
||||
# Active Storage Configuration - responsible for storing file uploads
|
||||
# ======================================================================================================
|
||||
|
|
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -94,12 +94,11 @@ node_modules/
|
|||
*.roo*
|
||||
# OS specific
|
||||
# Task files
|
||||
.taskmaster/docs
|
||||
.taskmaster/config.json
|
||||
.taskmaster/templates
|
||||
.taskmaster/
|
||||
tasks.json
|
||||
.taskmaster/tasks/
|
||||
.taskmaster/reports/
|
||||
.taskmaster/state.json
|
||||
*.mcp.json
|
||||
scripts/
|
||||
.cursor/mcp.json
|
||||
|
|
273
CLAUDE.md
Normal file
273
CLAUDE.md
Normal file
|
@ -0,0 +1,273 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Development Server
|
||||
- `bin/dev` - Start development server (Rails, Sidekiq, Tailwind CSS watcher)
|
||||
- `bin/rails server` - Start Rails server only
|
||||
- `bin/rails console` - Open Rails console
|
||||
|
||||
### Testing
|
||||
- `bin/rails test` - Run all tests
|
||||
- `bin/rails test:db` - Run tests with database reset
|
||||
- `bin/rails test:system` - Run system tests only (use sparingly - they take longer)
|
||||
- `bin/rails test test/models/account_test.rb` - Run specific test file
|
||||
- `bin/rails test test/models/account_test.rb:42` - Run specific test at line
|
||||
|
||||
### Linting & Formatting
|
||||
- `bin/rubocop` - Run Ruby linter
|
||||
- `npm run lint` - Check JavaScript/TypeScript code
|
||||
- `npm run lint:fix` - Fix JavaScript/TypeScript issues
|
||||
- `npm run format` - Format JavaScript/TypeScript code
|
||||
- `bin/brakeman` - Run security analysis
|
||||
|
||||
### Database
|
||||
- `bin/rails db:prepare` - Create and migrate database
|
||||
- `bin/rails db:migrate` - Run pending migrations
|
||||
- `bin/rails db:rollback` - Rollback last migration
|
||||
- `bin/rails db:seed` - Load seed data
|
||||
|
||||
### Setup
|
||||
- `bin/setup` - Initial project setup (installs dependencies, prepares database)
|
||||
|
||||
## Pre-Pull Request CI Workflow
|
||||
|
||||
ALWAYS run these commands before opening a pull request:
|
||||
|
||||
1. **Tests** (Required):
|
||||
- `bin/rails test` - Run all tests (always required)
|
||||
- `bin/rails test:system` - Run system tests (only when applicable, they take longer)
|
||||
|
||||
2. **Linting** (Required):
|
||||
- `bin/rubocop -f github -a` - Ruby linting with auto-correct
|
||||
- `bundle exec erb_lint ./app/**/*.erb -a` - ERB linting with auto-correct
|
||||
|
||||
3. **Security** (Required):
|
||||
- `bin/brakeman --no-pager` - Security analysis
|
||||
|
||||
Only proceed with pull request creation if ALL checks pass.
|
||||
|
||||
## General Development Rules
|
||||
|
||||
### Authentication Context
|
||||
- Use `Current.user` for the current user. Do NOT use `current_user`.
|
||||
- Use `Current.family` for the current family. Do NOT use `current_family`.
|
||||
|
||||
### Development Guidelines
|
||||
- Prior to generating any code, carefully read the project conventions and guidelines
|
||||
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development
|
||||
- Do not run `rails server` in your responses
|
||||
- Do not run `touch tmp/restart.txt`
|
||||
- Do not run `rails credentials`
|
||||
- Do not automatically run migrations
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
### Application Modes
|
||||
The Maybe app runs in two distinct modes:
|
||||
- **Managed**: The Maybe team operates and manages servers for users (Rails.application.config.app_mode = "managed")
|
||||
- **Self Hosted**: Users host the Maybe app on their own infrastructure, typically through Docker Compose (Rails.application.config.app_mode = "self_hosted")
|
||||
|
||||
### Core Domain Model
|
||||
The application is built around financial data management with these key relationships:
|
||||
- **User** → has many **Accounts** → has many **Transactions**
|
||||
- **Account** types: checking, savings, credit cards, investments, crypto, loans, properties
|
||||
- **Transaction** → belongs to **Category**, can have **Tags** and **Rules**
|
||||
- **Investment accounts** → have **Holdings** → track **Securities** via **Trades**
|
||||
|
||||
### API Architecture
|
||||
The application provides both internal and external APIs:
|
||||
- Internal API: Controllers serve JSON via Turbo for SPA-like interactions
|
||||
- External API: `/api/v1/` namespace with Doorkeeper OAuth and API key authentication
|
||||
- API responses use Jbuilder templates for JSON rendering
|
||||
- Rate limiting via Rack Attack with configurable limits per API key
|
||||
|
||||
### Sync & Import System
|
||||
Two primary data ingestion methods:
|
||||
1. **Plaid Integration**: Real-time bank account syncing
|
||||
- `PlaidItem` manages connections
|
||||
- `Sync` tracks sync operations
|
||||
- Background jobs handle data updates
|
||||
2. **CSV Import**: Manual data import with mapping
|
||||
- `Import` manages import sessions
|
||||
- Supports transaction and balance imports
|
||||
- Custom field mapping with transformation rules
|
||||
|
||||
### Background Processing
|
||||
Sidekiq handles asynchronous tasks:
|
||||
- Account syncing (`SyncAccountsJob`)
|
||||
- Import processing (`ImportDataJob`)
|
||||
- AI chat responses (`CreateChatResponseJob`)
|
||||
- Scheduled maintenance via sidekiq-cron
|
||||
|
||||
### Frontend Architecture
|
||||
- **Hotwire Stack**: Turbo + Stimulus for reactive UI without heavy JavaScript
|
||||
- **ViewComponents**: Reusable UI components in `app/components/`
|
||||
- **Stimulus Controllers**: Handle interactivity, organized alongside components
|
||||
- **Charts**: D3.js for financial visualizations (time series, donut, sankey)
|
||||
- **Styling**: Tailwind CSS v4.x with custom design system
|
||||
- Design system defined in `app/assets/tailwind/maybe-design-system.css`
|
||||
- Always use functional tokens (e.g., `text-primary` not `text-white`)
|
||||
- Prefer semantic HTML elements over JS components
|
||||
- Use `icon` helper for icons, never `lucide_icon` directly
|
||||
|
||||
### Multi-Currency Support
|
||||
- All monetary values stored in base currency (user's primary currency)
|
||||
- Exchange rates fetched from Synth API
|
||||
- `Money` objects handle currency conversion and formatting
|
||||
- Historical exchange rates for accurate reporting
|
||||
|
||||
### Security & Authentication
|
||||
- Session-based auth for web users
|
||||
- API authentication via:
|
||||
- OAuth2 (Doorkeeper) for third-party apps
|
||||
- API keys with JWT tokens for direct API access
|
||||
- Scoped permissions system for API access
|
||||
- Strong parameters and CSRF protection throughout
|
||||
|
||||
### Testing Philosophy
|
||||
- Comprehensive test coverage using Rails' built-in Minitest
|
||||
- Fixtures for test data (avoid FactoryBot)
|
||||
- Keep fixtures minimal (2-3 per model for base cases)
|
||||
- VCR for external API testing
|
||||
- System tests for critical user flows (use sparingly)
|
||||
- Test helpers in `test/support/` for common scenarios
|
||||
- Only test critical code paths that significantly increase confidence
|
||||
- Write tests as you go, when required
|
||||
|
||||
### Performance Considerations
|
||||
- Database queries optimized with proper indexes
|
||||
- N+1 queries prevented via includes/joins
|
||||
- Background jobs for heavy operations
|
||||
- Caching strategies for expensive calculations
|
||||
- Turbo Frames for partial page updates
|
||||
|
||||
### Development Workflow
|
||||
- Feature branches merged to `main`
|
||||
- Docker support for consistent environments
|
||||
- Environment variables via `.env` files
|
||||
- Lookbook for component development (`/lookbook`)
|
||||
- Letter Opener for email preview in development
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### Convention 1: Minimize Dependencies
|
||||
- Push Rails to its limits before adding new dependencies
|
||||
- Strong technical/business reason required for new dependencies
|
||||
- Favor old and reliable over new and flashy
|
||||
|
||||
### Convention 2: Skinny Controllers, Fat Models
|
||||
- Business logic in `app/models/` folder, avoid `app/services/`
|
||||
- Use Rails concerns and POROs for organization
|
||||
- Models should answer questions about themselves: `account.balance_series` not `AccountSeries.new(account).call`
|
||||
|
||||
### Convention 3: Hotwire-First Frontend
|
||||
- **Native HTML preferred over JS components**
|
||||
- Use `<dialog>` for modals, `<details><summary>` for disclosures
|
||||
- **Leverage Turbo frames** for page sections over client-side solutions
|
||||
- **Query params for state** over localStorage/sessions
|
||||
- **Server-side formatting** for currencies, numbers, dates
|
||||
- **Always use `icon` helper** in `application_helper.rb`, NEVER `lucide_icon` directly
|
||||
|
||||
### Convention 4: Optimize for Simplicity
|
||||
- Prioritize good OOP domain design over performance
|
||||
- Focus performance only on critical/global areas (avoid N+1 queries, mindful of global layouts)
|
||||
|
||||
### Convention 5: Database vs ActiveRecord Validations
|
||||
- Simple validations (null checks, unique indexes) in DB
|
||||
- ActiveRecord validations for convenience in forms (prefer client-side when possible)
|
||||
- Complex validations and business logic in ActiveRecord
|
||||
|
||||
## TailwindCSS Design System
|
||||
|
||||
### Design System Rules
|
||||
- **Always reference `app/assets/tailwind/maybe-design-system.css`** for primitives and tokens
|
||||
- **Use functional tokens** defined in design system:
|
||||
- `text-primary` instead of `text-white`
|
||||
- `bg-container` instead of `bg-white`
|
||||
- `border border-primary` instead of `border border-gray-200`
|
||||
- **NEVER create new styles** in design system files without permission
|
||||
- **Always generate semantic HTML**
|
||||
|
||||
## Component Architecture
|
||||
|
||||
### ViewComponent vs Partials Decision Making
|
||||
|
||||
**Use ViewComponents when:**
|
||||
- Element has complex logic or styling patterns
|
||||
- Element will be reused across multiple views/contexts
|
||||
- Element needs structured styling with variants/sizes
|
||||
- Element requires interactive behavior or Stimulus controllers
|
||||
- Element has configurable slots or complex APIs
|
||||
- Element needs accessibility features or ARIA support
|
||||
|
||||
**Use Partials when:**
|
||||
- Element is primarily static HTML with minimal logic
|
||||
- Element is used in only one or few specific contexts
|
||||
- Element is simple template content
|
||||
- Element doesn't need variants, sizes, or complex configuration
|
||||
- Element is more about content organization than reusable functionality
|
||||
|
||||
**Component Guidelines:**
|
||||
- Prefer components over partials when available
|
||||
- Keep domain logic OUT of view templates
|
||||
- Logic belongs in component files, not template files
|
||||
|
||||
### Stimulus Controller Guidelines
|
||||
|
||||
**Declarative Actions (Required):**
|
||||
```erb
|
||||
<!-- GOOD: Declarative - HTML declares what happens -->
|
||||
<div data-controller="toggle">
|
||||
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
|
||||
<div data-toggle-target="content" class="hidden">Hello World!</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Controller Best Practices:**
|
||||
- Keep controllers lightweight and simple (< 7 targets)
|
||||
- Use private methods and expose clear public API
|
||||
- Single responsibility or highly related responsibilities
|
||||
- Component controllers stay in component directory, global controllers in `app/javascript/controllers/`
|
||||
- Pass data via `data-*-value` attributes, not inline JavaScript
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
### General Testing Rules
|
||||
- **ALWAYS use Minitest + fixtures** (NEVER RSpec or factories)
|
||||
- Keep fixtures minimal (2-3 per model for base cases)
|
||||
- Create edge cases on-the-fly within test context
|
||||
- Use Rails helpers for large fixture creation needs
|
||||
|
||||
### Test Quality Guidelines
|
||||
- **Write minimal, effective tests** - system tests sparingly
|
||||
- **Only test critical and important code paths**
|
||||
- **Test boundaries correctly:**
|
||||
- Commands: test they were called with correct params
|
||||
- Queries: test output
|
||||
- Don't test implementation details of other classes
|
||||
|
||||
### Testing Examples
|
||||
|
||||
```ruby
|
||||
# GOOD - Testing critical domain business logic
|
||||
test "syncs balances" do
|
||||
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||
assert_difference "@account.balances.count", 2 do
|
||||
Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||
end
|
||||
end
|
||||
|
||||
# BAD - Testing ActiveRecord functionality
|
||||
test "saves balance" do
|
||||
balance_record = Balance.new(balance: 100, currency: "USD")
|
||||
assert balance_record.save
|
||||
end
|
||||
```
|
||||
|
||||
### Stubs and Mocks
|
||||
- Use `mocha` gem
|
||||
- Prefer `OpenStruct` for mock instances
|
||||
- Only mock what's necessary
|
7
Gemfile
7
Gemfile
|
@ -26,7 +26,7 @@ gem "view_component"
|
|||
|
||||
# https://github.com/lookbook-hq/lookbook/issues/712
|
||||
# TODO: Remove max version constraint when fixed
|
||||
gem "lookbook", "2.3.9"
|
||||
gem "lookbook", "2.3.11"
|
||||
|
||||
gem "hotwire_combobox"
|
||||
|
||||
|
@ -51,6 +51,11 @@ gem "image_processing", ">= 1.2"
|
|||
gem "ostruct"
|
||||
gem "bcrypt", "~> 3.1"
|
||||
gem "jwt"
|
||||
gem "jbuilder"
|
||||
|
||||
# OAuth & API Security
|
||||
gem "doorkeeper"
|
||||
gem "rack-attack", "~> 6.6"
|
||||
gem "faraday"
|
||||
gem "faraday-retry"
|
||||
gem "faraday-multipart"
|
||||
|
|
24
Gemfile.lock
24
Gemfile.lock
|
@ -151,7 +151,7 @@ GEM
|
|||
addressable
|
||||
csv (3.3.5)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
debug (1.11.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
derailed_benchmarks (2.2.1)
|
||||
|
@ -173,6 +173,8 @@ GEM
|
|||
ruby2_keywords
|
||||
thor (>= 0.19, < 2)
|
||||
docile (1.4.1)
|
||||
doorkeeper (5.8.2)
|
||||
railties (>= 5)
|
||||
dotenv (3.1.8)
|
||||
dotenv-rails (3.1.8)
|
||||
dotenv (= 3.1.8)
|
||||
|
@ -196,11 +198,11 @@ GEM
|
|||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-multipart (1.1.0)
|
||||
faraday-multipart (1.1.1)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.1)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-retry (2.3.1)
|
||||
faraday-retry (2.3.2)
|
||||
faraday (~> 2.0)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
|
@ -266,6 +268,9 @@ GEM
|
|||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.13.0)
|
||||
actionview (>= 5.0.0)
|
||||
activesupport (>= 5.0.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.12.2)
|
||||
jwt (2.10.1)
|
||||
|
@ -296,7 +301,7 @@ GEM
|
|||
loofah (2.24.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
lookbook (2.3.9)
|
||||
lookbook (2.3.11)
|
||||
activemodel
|
||||
css_parser
|
||||
htmlbeautifier (~> 1.3)
|
||||
|
@ -359,7 +364,7 @@ GEM
|
|||
octokit (10.0.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
ostruct (0.6.1)
|
||||
ostruct (0.6.2)
|
||||
pagy (9.3.4)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
|
@ -390,6 +395,8 @@ GEM
|
|||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.16)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-mini-profiler (4.0.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-session (2.1.1)
|
||||
|
@ -441,7 +448,7 @@ GEM
|
|||
ffi (~> 1.0)
|
||||
rbs (3.9.4)
|
||||
logger
|
||||
rdoc (6.14.0)
|
||||
rdoc (6.14.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.1)
|
||||
|
@ -625,6 +632,7 @@ DEPENDENCIES
|
|||
csv
|
||||
debug
|
||||
derailed_benchmarks
|
||||
doorkeeper
|
||||
dotenv-rails
|
||||
erb_lint
|
||||
faker
|
||||
|
@ -639,10 +647,11 @@ DEPENDENCIES
|
|||
importmap-rails
|
||||
inline_svg
|
||||
intercom-rails
|
||||
jbuilder
|
||||
jwt
|
||||
letter_opener
|
||||
logtail-rails
|
||||
lookbook (= 2.3.9)
|
||||
lookbook (= 2.3.11)
|
||||
lucide-rails!
|
||||
mocha
|
||||
octokit
|
||||
|
@ -652,6 +661,7 @@ DEPENDENCIES
|
|||
plaid
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rack-attack (~> 6.6)
|
||||
rack-mini-profiler
|
||||
rails (~> 7.2.2)
|
||||
rails-settings-cached
|
||||
|
|
59
app/controllers/api/v1/accounts_controller.rb
Normal file
59
app/controllers/api/v1/accounts_controller.rb
Normal file
|
@ -0,0 +1,59 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::AccountsController < Api::V1::BaseController
|
||||
include Pagy::Backend
|
||||
|
||||
# Ensure proper scope authorization for read access
|
||||
before_action :ensure_read_scope
|
||||
|
||||
def index
|
||||
# Test with Pagy pagination
|
||||
family = current_resource_owner.family
|
||||
accounts_query = family.accounts.active.alphabetically
|
||||
|
||||
# Handle pagination with Pagy
|
||||
@pagy, @accounts = pagy(
|
||||
accounts_query,
|
||||
page: safe_page_param,
|
||||
limit: safe_per_page_param
|
||||
)
|
||||
|
||||
@per_page = safe_per_page_param
|
||||
|
||||
# Rails will automatically use app/views/api/v1/accounts/index.json.jbuilder
|
||||
render :index
|
||||
rescue => e
|
||||
Rails.logger.error "AccountsController error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "Error: #{e.message}"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_read_scope
|
||||
authorize_scope!(:read)
|
||||
end
|
||||
|
||||
|
||||
|
||||
def safe_page_param
|
||||
page = params[:page].to_i
|
||||
page > 0 ? page : 1
|
||||
end
|
||||
|
||||
def safe_per_page_param
|
||||
per_page = params[:per_page].to_i
|
||||
|
||||
# Default to 25, max 100
|
||||
case per_page
|
||||
when 1..100
|
||||
per_page
|
||||
else
|
||||
25
|
||||
end
|
||||
end
|
||||
end
|
210
app/controllers/api/v1/auth_controller.rb
Normal file
210
app/controllers/api/v1/auth_controller.rb
Normal file
|
@ -0,0 +1,210 @@
|
|||
module Api
|
||||
module V1
|
||||
class AuthController < BaseController
|
||||
include Invitable
|
||||
|
||||
skip_before_action :authenticate_request!
|
||||
skip_before_action :check_api_key_rate_limit
|
||||
skip_before_action :log_api_access
|
||||
|
||||
def signup
|
||||
# Check if invite code is required
|
||||
if invite_code_required? && params[:invite_code].blank?
|
||||
render json: { error: "Invite code is required" }, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
# Validate invite code if provided
|
||||
if params[:invite_code].present? && !InviteCode.exists?(token: params[:invite_code]&.downcase)
|
||||
render json: { error: "Invalid invite code" }, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
# Validate password
|
||||
password_errors = validate_password(params[:user][:password])
|
||||
if password_errors.any?
|
||||
render json: { errors: password_errors }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Validate device info
|
||||
unless valid_device_info?
|
||||
render json: { error: "Device information is required" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
user = User.new(user_signup_params)
|
||||
|
||||
# Create family for new user
|
||||
family = Family.new
|
||||
user.family = family
|
||||
user.role = :admin
|
||||
|
||||
if user.save
|
||||
# Claim invite code if provided
|
||||
InviteCode.claim!(params[:invite_code]) if params[:invite_code].present?
|
||||
|
||||
# Create device and OAuth token
|
||||
device = create_or_update_device(user)
|
||||
token_response = create_oauth_token_for_device(user, device)
|
||||
|
||||
render json: token_response.merge(
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name
|
||||
}
|
||||
), status: :created
|
||||
else
|
||||
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def login
|
||||
user = User.find_by(email: params[:email])
|
||||
|
||||
if user&.authenticate(params[:password])
|
||||
# Check MFA if enabled
|
||||
if user.otp_required?
|
||||
unless params[:otp_code].present? && user.verify_otp?(params[:otp_code])
|
||||
render json: {
|
||||
error: "Two-factor authentication required",
|
||||
mfa_required: true
|
||||
}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Validate device info
|
||||
unless valid_device_info?
|
||||
render json: { error: "Device information is required" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Create device and OAuth token
|
||||
device = create_or_update_device(user)
|
||||
token_response = create_oauth_token_for_device(user, device)
|
||||
|
||||
render json: token_response.merge(
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name
|
||||
}
|
||||
)
|
||||
else
|
||||
render json: { error: "Invalid email or password" }, status: :unauthorized
|
||||
end
|
||||
end
|
||||
|
||||
def refresh
|
||||
# Find the refresh token
|
||||
refresh_token = params[:refresh_token]
|
||||
|
||||
unless refresh_token.present?
|
||||
render json: { error: "Refresh token is required" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Find the access token associated with this refresh token
|
||||
access_token = Doorkeeper::AccessToken.by_refresh_token(refresh_token)
|
||||
|
||||
if access_token.nil? || access_token.revoked?
|
||||
render json: { error: "Invalid refresh token" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Create new access token
|
||||
new_token = Doorkeeper::AccessToken.create!(
|
||||
application: access_token.application,
|
||||
resource_owner_id: access_token.resource_owner_id,
|
||||
expires_in: 30.days.to_i,
|
||||
scopes: access_token.scopes,
|
||||
use_refresh_token: true
|
||||
)
|
||||
|
||||
# Revoke old access token
|
||||
access_token.revoke
|
||||
|
||||
# Update device last seen
|
||||
user = User.find(access_token.resource_owner_id)
|
||||
device = user.mobile_devices.find_by(device_id: params[:device][:device_id])
|
||||
device&.update_last_seen!
|
||||
|
||||
render json: {
|
||||
access_token: new_token.plaintext_token,
|
||||
refresh_token: new_token.plaintext_refresh_token,
|
||||
token_type: "Bearer",
|
||||
expires_in: new_token.expires_in,
|
||||
created_at: new_token.created_at.to_i
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_signup_params
|
||||
params.require(:user).permit(:email, :password, :first_name, :last_name)
|
||||
end
|
||||
|
||||
def validate_password(password)
|
||||
errors = []
|
||||
|
||||
if password.blank?
|
||||
errors << "Password can't be blank"
|
||||
return errors
|
||||
end
|
||||
|
||||
errors << "Password must be at least 8 characters" if password.length < 8
|
||||
errors << "Password must include both uppercase and lowercase letters" unless password.match?(/[A-Z]/) && password.match?(/[a-z]/)
|
||||
errors << "Password must include at least one number" unless password.match?(/\d/)
|
||||
errors << "Password must include at least one special character" unless password.match?(/[!@#$%^&*(),.?":{}|<>]/)
|
||||
|
||||
errors
|
||||
end
|
||||
|
||||
def valid_device_info?
|
||||
device = params[:device]
|
||||
return false if device.nil?
|
||||
|
||||
required_fields = %w[device_id device_name device_type os_version app_version]
|
||||
required_fields.all? { |field| device[field].present? }
|
||||
end
|
||||
|
||||
def create_or_update_device(user)
|
||||
# Handle both string and symbol keys
|
||||
device_data = params[:device].permit(:device_id, :device_name, :device_type, :os_version, :app_version)
|
||||
|
||||
device = user.mobile_devices.find_or_initialize_by(device_id: device_data[:device_id])
|
||||
device.update!(device_data.merge(last_seen_at: Time.current))
|
||||
device
|
||||
end
|
||||
|
||||
def create_oauth_token_for_device(user, device)
|
||||
# Create OAuth application for this device if needed
|
||||
oauth_app = device.create_oauth_application!
|
||||
|
||||
# Revoke any existing tokens for this device
|
||||
device.revoke_all_tokens!
|
||||
|
||||
# Create new access token with 30-day expiration
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: oauth_app,
|
||||
resource_owner_id: user.id,
|
||||
expires_in: 30.days.to_i,
|
||||
scopes: "read_write",
|
||||
use_refresh_token: true
|
||||
)
|
||||
|
||||
{
|
||||
access_token: access_token.plaintext_token,
|
||||
refresh_token: access_token.plaintext_refresh_token,
|
||||
token_type: "Bearer",
|
||||
expires_in: access_token.expires_in,
|
||||
created_at: access_token.created_at.to_i
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
279
app/controllers/api/v1/base_controller.rb
Normal file
279
app/controllers/api/v1/base_controller.rb
Normal file
|
@ -0,0 +1,279 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::BaseController < ApplicationController
|
||||
include Doorkeeper::Rails::Helpers
|
||||
|
||||
# Skip regular session-based authentication for API
|
||||
skip_authentication
|
||||
|
||||
# Skip CSRF protection for API endpoints
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
# Skip onboarding requirements for API endpoints
|
||||
skip_before_action :require_onboarding_and_upgrade
|
||||
|
||||
# Force JSON format for all API requests
|
||||
before_action :force_json_format
|
||||
# Use our custom authentication that supports both OAuth and API keys
|
||||
before_action :authenticate_request!
|
||||
before_action :check_api_key_rate_limit
|
||||
before_action :log_api_access
|
||||
|
||||
|
||||
|
||||
# Override Doorkeeper's default behavior to return JSON instead of redirecting
|
||||
def doorkeeper_unauthorized_render_options(error: nil)
|
||||
{ json: { error: "unauthorized", message: "Access token is invalid, expired, or missing" } }
|
||||
end
|
||||
|
||||
# Error handling for common API errors
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
|
||||
rescue_from Doorkeeper::Errors::DoorkeeperError, with: :handle_unauthorized
|
||||
rescue_from ActionController::ParameterMissing, with: :handle_bad_request
|
||||
|
||||
private
|
||||
|
||||
# Force JSON format for all API requests
|
||||
def force_json_format
|
||||
request.format = :json
|
||||
end
|
||||
|
||||
# Authenticate using either OAuth or API key
|
||||
def authenticate_request!
|
||||
return if authenticate_oauth
|
||||
return if authenticate_api_key
|
||||
render_unauthorized unless performed?
|
||||
end
|
||||
|
||||
# Try OAuth authentication first
|
||||
def authenticate_oauth
|
||||
return false unless request.headers["Authorization"].present?
|
||||
|
||||
# Manually verify the token (bypassing doorkeeper_authorize! which had scope issues)
|
||||
token_string = request.authorization&.split(" ")&.last
|
||||
access_token = Doorkeeper::AccessToken.by_token(token_string)
|
||||
|
||||
# Check token validity and scope (read_write includes read access)
|
||||
has_sufficient_scope = access_token&.scopes&.include?("read") || access_token&.scopes&.include?("read_write")
|
||||
|
||||
unless access_token && !access_token.expired? && has_sufficient_scope
|
||||
render_json({ error: "unauthorized", message: "Access token is invalid, expired, or missing required scope" }, status: :unauthorized)
|
||||
return false
|
||||
end
|
||||
|
||||
# Set the doorkeeper_token for compatibility
|
||||
@_doorkeeper_token = access_token
|
||||
|
||||
if doorkeeper_token&.resource_owner_id
|
||||
@current_user = User.find_by(id: doorkeeper_token.resource_owner_id)
|
||||
|
||||
# If user doesn't exist, the token is invalid (user was deleted)
|
||||
unless @current_user
|
||||
Rails.logger.warn "API OAuth Token Invalid: Access token resource_owner_id #{doorkeeper_token.resource_owner_id} does not exist"
|
||||
render_json({ error: "unauthorized", message: "Access token is invalid - user not found" }, status: :unauthorized)
|
||||
return false
|
||||
end
|
||||
else
|
||||
Rails.logger.warn "API OAuth Token Invalid: Access token missing resource_owner_id"
|
||||
render_json({ error: "unauthorized", message: "Access token is invalid - missing resource owner" }, status: :unauthorized)
|
||||
return false
|
||||
end
|
||||
|
||||
@authentication_method = :oauth
|
||||
setup_current_context_for_api
|
||||
true
|
||||
rescue Doorkeeper::Errors::DoorkeeperError => e
|
||||
Rails.logger.warn "API OAuth Error: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
# Try API key authentication
|
||||
def authenticate_api_key
|
||||
api_key_value = request.headers["X-Api-Key"]
|
||||
return false unless api_key_value
|
||||
|
||||
@api_key = ApiKey.find_by_value(api_key_value)
|
||||
return false unless @api_key && @api_key.active?
|
||||
|
||||
@current_user = @api_key.user
|
||||
@api_key.update_last_used!
|
||||
@authentication_method = :api_key
|
||||
@rate_limiter = ApiRateLimiter.new(@api_key)
|
||||
setup_current_context_for_api
|
||||
true
|
||||
end
|
||||
|
||||
# Check rate limits for API key authentication
|
||||
def check_api_key_rate_limit
|
||||
return unless @authentication_method == :api_key && @rate_limiter
|
||||
|
||||
if @rate_limiter.rate_limit_exceeded?
|
||||
usage_info = @rate_limiter.usage_info
|
||||
render_rate_limit_exceeded(usage_info)
|
||||
return false
|
||||
end
|
||||
|
||||
# Increment request count for successful API key requests
|
||||
@rate_limiter.increment_request_count!
|
||||
|
||||
# Add rate limit headers to response
|
||||
add_rate_limit_headers(@rate_limiter.usage_info)
|
||||
end
|
||||
|
||||
# Render rate limit exceeded response
|
||||
def render_rate_limit_exceeded(usage_info)
|
||||
response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s
|
||||
response.headers["X-RateLimit-Remaining"] = "0"
|
||||
response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s
|
||||
response.headers["Retry-After"] = usage_info[:reset_time].to_s
|
||||
|
||||
Rails.logger.warn "API Rate Limit Exceeded: API Key #{@api_key.name} (User: #{@current_user.email}) - #{usage_info[:current_count]}/#{usage_info[:rate_limit]} requests"
|
||||
|
||||
render_json({
|
||||
error: "rate_limit_exceeded",
|
||||
message: "Rate limit exceeded. Try again in #{usage_info[:reset_time]} seconds.",
|
||||
details: {
|
||||
limit: usage_info[:rate_limit],
|
||||
current: usage_info[:current_count],
|
||||
reset_in_seconds: usage_info[:reset_time]
|
||||
}
|
||||
}, status: :too_many_requests)
|
||||
end
|
||||
|
||||
# Add rate limit headers to successful responses
|
||||
def add_rate_limit_headers(usage_info)
|
||||
response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s
|
||||
response.headers["X-RateLimit-Remaining"] = usage_info[:remaining].to_s
|
||||
response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s
|
||||
end
|
||||
|
||||
# Render unauthorized response
|
||||
def render_unauthorized
|
||||
render_json({ error: "unauthorized", message: "Access token or API key is invalid, expired, or missing" }, status: :unauthorized)
|
||||
end
|
||||
|
||||
# Returns the user that owns the access token or API key
|
||||
def current_resource_owner
|
||||
@current_user
|
||||
end
|
||||
|
||||
# Get current scopes from either authentication method
|
||||
def current_scopes
|
||||
case @authentication_method
|
||||
when :oauth
|
||||
doorkeeper_token&.scopes&.to_a || []
|
||||
when :api_key
|
||||
@api_key&.scopes || []
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Check if the current authentication has the required scope
|
||||
# Implements hierarchical scope checking where read_write includes read access
|
||||
def authorize_scope!(required_scope)
|
||||
scopes = current_scopes
|
||||
|
||||
case required_scope.to_s
|
||||
when "read"
|
||||
# Read access requires either "read" or "read_write" scope
|
||||
has_access = scopes.include?("read") || scopes.include?("read_write")
|
||||
when "write"
|
||||
# Write access requires "read_write" scope
|
||||
has_access = scopes.include?("read_write")
|
||||
else
|
||||
# For any other scope, check exact match (backward compatibility)
|
||||
has_access = scopes.include?(required_scope.to_s)
|
||||
end
|
||||
|
||||
unless has_access
|
||||
Rails.logger.warn "API Insufficient Scope: User #{current_resource_owner&.email} attempted to access #{required_scope} but only has #{scopes}"
|
||||
render_json({ error: "insufficient_scope", message: "This action requires the '#{required_scope}' scope" }, status: :forbidden)
|
||||
return false
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
# Consistent JSON response method
|
||||
def render_json(data, status: :ok)
|
||||
render json: data, status: status
|
||||
end
|
||||
|
||||
# Error handlers
|
||||
def handle_not_found(exception)
|
||||
Rails.logger.warn "API Record Not Found: #{exception.message}"
|
||||
render_json({ error: "record_not_found", message: "The requested resource was not found" }, status: :not_found)
|
||||
end
|
||||
|
||||
def handle_unauthorized(exception)
|
||||
Rails.logger.warn "API Unauthorized: #{exception.message}"
|
||||
render_json({ error: "unauthorized", message: "Access token is invalid or expired" }, status: :unauthorized)
|
||||
end
|
||||
|
||||
def handle_bad_request(exception)
|
||||
Rails.logger.warn "API Bad Request: #{exception.message}"
|
||||
render_json({ error: "bad_request", message: "Required parameters are missing or invalid" }, status: :bad_request)
|
||||
end
|
||||
|
||||
# Log API access for monitoring and debugging
|
||||
def log_api_access
|
||||
return unless current_resource_owner
|
||||
|
||||
auth_info = case @authentication_method
|
||||
when :oauth
|
||||
"OAuth Token"
|
||||
when :api_key
|
||||
"API Key: #{@api_key.name}"
|
||||
else
|
||||
"Unknown"
|
||||
end
|
||||
|
||||
Rails.logger.info "API Request: #{request.method} #{request.path} - User: #{current_resource_owner.email} (Family: #{current_resource_owner.family_id}) - Auth: #{auth_info}"
|
||||
end
|
||||
|
||||
# Family-based access control helper (to be used by subcontrollers)
|
||||
def ensure_current_family_access(resource)
|
||||
return unless resource.respond_to?(:family_id)
|
||||
|
||||
unless resource.family_id == current_resource_owner.family_id
|
||||
Rails.logger.warn "API Forbidden: User #{current_resource_owner.email} attempted to access resource from family #{resource.family_id}"
|
||||
render_json({ error: "forbidden", message: "Access denied to this resource" }, status: :forbidden)
|
||||
return false
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# Manual doorkeeper_token accessor for compatibility with manual token verification
|
||||
def doorkeeper_token
|
||||
@_doorkeeper_token
|
||||
end
|
||||
|
||||
# Set up Current context for API requests since we don't use session-based auth
|
||||
def setup_current_context_for_api
|
||||
# For API requests, we need to create a minimal session-like object
|
||||
# or find/create an actual session for this user to make Current.user work
|
||||
if @current_user
|
||||
# Try to find an existing session for this user, or create a temporary one
|
||||
session = @current_user.sessions.first
|
||||
if session
|
||||
Current.session = session
|
||||
else
|
||||
# Create a temporary session for this API request
|
||||
# This won't be persisted but will allow Current.user to work
|
||||
session = @current_user.sessions.build(
|
||||
user_agent: request.user_agent,
|
||||
ip_address: request.ip
|
||||
)
|
||||
Current.session = session
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Check if AI features are enabled for the current user
|
||||
def require_ai_enabled
|
||||
unless current_resource_owner&.ai_enabled?
|
||||
render_json({ error: "feature_disabled", message: "AI features are not enabled for this user" }, status: :forbidden)
|
||||
end
|
||||
end
|
||||
end
|
84
app/controllers/api/v1/chats_controller.rb
Normal file
84
app/controllers/api/v1/chats_controller.rb
Normal file
|
@ -0,0 +1,84 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::ChatsController < Api::V1::BaseController
|
||||
include Pagy::Backend
|
||||
before_action :require_ai_enabled
|
||||
before_action :ensure_read_scope, only: [ :index, :show ]
|
||||
before_action :ensure_write_scope, only: [ :create, :update, :destroy ]
|
||||
before_action :set_chat, only: [ :show, :update, :destroy ]
|
||||
|
||||
def index
|
||||
@pagy, @chats = pagy(Current.user.chats.ordered, items: 20)
|
||||
end
|
||||
|
||||
def show
|
||||
return unless @chat
|
||||
@pagy, @messages = pagy(@chat.messages.ordered, items: 50)
|
||||
end
|
||||
|
||||
def create
|
||||
@chat = Current.user.chats.build(title: chat_params[:title])
|
||||
|
||||
if @chat.save
|
||||
if chat_params[:message].present?
|
||||
@message = @chat.messages.build(
|
||||
content: chat_params[:message],
|
||||
type: "UserMessage",
|
||||
ai_model: chat_params[:model] || "gpt-4"
|
||||
)
|
||||
|
||||
if @message.save
|
||||
AssistantResponseJob.perform_later(@message)
|
||||
render :show, status: :created
|
||||
else
|
||||
@chat.destroy
|
||||
render json: { error: "Failed to create initial message", details: @message.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
render :show, status: :created
|
||||
end
|
||||
else
|
||||
render json: { error: "Failed to create chat", details: @chat.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
return unless @chat
|
||||
|
||||
if @chat.update(update_chat_params)
|
||||
render :show
|
||||
else
|
||||
render json: { error: "Failed to update chat", details: @chat.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
return unless @chat
|
||||
@chat.destroy
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_read_scope
|
||||
authorize_scope!(:read)
|
||||
end
|
||||
|
||||
def ensure_write_scope
|
||||
authorize_scope!(:write)
|
||||
end
|
||||
|
||||
def set_chat
|
||||
@chat = Current.user.chats.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "Chat not found" }, status: :not_found
|
||||
end
|
||||
|
||||
def chat_params
|
||||
params.permit(:title, :message, :model)
|
||||
end
|
||||
|
||||
def update_chat_params
|
||||
params.permit(:title)
|
||||
end
|
||||
end
|
55
app/controllers/api/v1/messages_controller.rb
Normal file
55
app/controllers/api/v1/messages_controller.rb
Normal file
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::MessagesController < Api::V1::BaseController
|
||||
before_action :require_ai_enabled
|
||||
before_action :ensure_write_scope, only: [ :create, :retry ]
|
||||
before_action :set_chat
|
||||
|
||||
def create
|
||||
@message = @chat.messages.build(
|
||||
content: message_params[:content],
|
||||
type: "UserMessage",
|
||||
ai_model: message_params[:model] || "gpt-4"
|
||||
)
|
||||
|
||||
if @message.save
|
||||
AssistantResponseJob.perform_later(@message)
|
||||
render :show, status: :created
|
||||
else
|
||||
render json: { error: "Failed to create message", details: @message.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def retry
|
||||
last_message = @chat.messages.ordered.last
|
||||
|
||||
if last_message&.type == "AssistantMessage"
|
||||
new_message = @chat.messages.create!(
|
||||
type: "AssistantMessage",
|
||||
content: "",
|
||||
ai_model: last_message.ai_model
|
||||
)
|
||||
|
||||
AssistantResponseJob.perform_later(new_message)
|
||||
render json: { message: "Retry initiated", message_id: new_message.id }, status: :accepted
|
||||
else
|
||||
render json: { error: "No assistant message to retry" }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_write_scope
|
||||
authorize_scope!(:write)
|
||||
end
|
||||
|
||||
def set_chat
|
||||
@chat = Current.user.chats.find(params[:chat_id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "Chat not found" }, status: :not_found
|
||||
end
|
||||
|
||||
def message_params
|
||||
params.permit(:content, :model)
|
||||
end
|
||||
end
|
47
app/controllers/api/v1/test_controller.rb
Normal file
47
app/controllers/api/v1/test_controller.rb
Normal file
|
@ -0,0 +1,47 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Test controller for API V1 Base Controller functionality
|
||||
# This controller is only used for testing the base controller behavior
|
||||
class Api::V1::TestController < Api::V1::BaseController
|
||||
def index
|
||||
render_json({ message: "test_success", user: current_resource_owner&.email })
|
||||
end
|
||||
|
||||
def not_found
|
||||
# Trigger RecordNotFound error for testing error handling
|
||||
raise ActiveRecord::RecordNotFound, "Test record not found"
|
||||
end
|
||||
|
||||
def family_access
|
||||
# Test family-based access control
|
||||
# Create a mock resource that belongs to a different family
|
||||
mock_resource = OpenStruct.new(family_id: 999) # Different family ID
|
||||
|
||||
# Check family access - if it returns false, it already rendered the error
|
||||
if ensure_current_family_access(mock_resource)
|
||||
# If we get here, access was allowed
|
||||
render_json({ family_id: current_resource_owner.family_id })
|
||||
end
|
||||
end
|
||||
|
||||
def scope_required
|
||||
# Test scope authorization - require write scope
|
||||
return unless authorize_scope!("write")
|
||||
|
||||
render_json({
|
||||
message: "scope_authorized",
|
||||
scopes: current_scopes,
|
||||
required_scope: "write"
|
||||
})
|
||||
end
|
||||
|
||||
def multiple_scopes_required
|
||||
# Test read scope requirement
|
||||
return unless authorize_scope!("read")
|
||||
|
||||
render_json({
|
||||
message: "read_scope_authorized",
|
||||
scopes: current_scopes
|
||||
})
|
||||
end
|
||||
end
|
327
app/controllers/api/v1/transactions_controller.rb
Normal file
327
app/controllers/api/v1/transactions_controller.rb
Normal file
|
@ -0,0 +1,327 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::TransactionsController < Api::V1::BaseController
|
||||
include Pagy::Backend
|
||||
|
||||
# Ensure proper scope authorization for read vs write access
|
||||
before_action :ensure_read_scope, only: [ :index, :show ]
|
||||
before_action :ensure_write_scope, only: [ :create, :update, :destroy ]
|
||||
before_action :set_transaction, only: [ :show, :update, :destroy ]
|
||||
|
||||
def index
|
||||
family = current_resource_owner.family
|
||||
transactions_query = family.transactions.active
|
||||
|
||||
# Apply filters
|
||||
transactions_query = apply_filters(transactions_query)
|
||||
|
||||
# Apply search
|
||||
transactions_query = apply_search(transactions_query) if params[:search].present?
|
||||
|
||||
# Include necessary associations for efficient queries
|
||||
transactions_query = transactions_query.includes(
|
||||
{ entry: :account },
|
||||
:category, :merchant, :tags,
|
||||
transfer_as_outflow: { inflow_transaction: { entry: :account } },
|
||||
transfer_as_inflow: { outflow_transaction: { entry: :account } }
|
||||
).reverse_chronological
|
||||
|
||||
# Handle pagination with Pagy
|
||||
@pagy, @transactions = pagy(
|
||||
transactions_query,
|
||||
page: safe_page_param,
|
||||
limit: safe_per_page_param
|
||||
)
|
||||
|
||||
# Make per_page available to the template
|
||||
@per_page = safe_per_page_param
|
||||
|
||||
# Rails will automatically use app/views/api/v1/transactions/index.json.jbuilder
|
||||
render :index
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "TransactionsController#index error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "Error: #{e.message}"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
def show
|
||||
# Rails will automatically use app/views/api/v1/transactions/show.json.jbuilder
|
||||
render :show
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "TransactionsController#show error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "Error: #{e.message}"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
def create
|
||||
family = current_resource_owner.family
|
||||
|
||||
# Validate account_id is present
|
||||
unless transaction_params[:account_id].present?
|
||||
render json: {
|
||||
error: "validation_failed",
|
||||
message: "Account ID is required",
|
||||
errors: [ "Account ID is required" ]
|
||||
}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
account = family.accounts.find(transaction_params[:account_id])
|
||||
@entry = account.entries.new(entry_params_for_create)
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
@entry.lock_saved_attributes!
|
||||
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
||||
|
||||
@transaction = @entry.transaction
|
||||
render :show, status: :created
|
||||
else
|
||||
render json: {
|
||||
error: "validation_failed",
|
||||
message: "Transaction could not be created",
|
||||
errors: @entry.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "TransactionsController#create error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "Error: #{e.message}"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(entry_params_for_update)
|
||||
@entry.sync_account_later
|
||||
@entry.lock_saved_attributes!
|
||||
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
||||
|
||||
@transaction = @entry.transaction
|
||||
render :show
|
||||
else
|
||||
render json: {
|
||||
error: "validation_failed",
|
||||
message: "Transaction could not be updated",
|
||||
errors: @entry.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "TransactionsController#update error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "Error: #{e.message}"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
def destroy
|
||||
@entry.destroy!
|
||||
@entry.sync_account_later
|
||||
|
||||
render json: {
|
||||
message: "Transaction deleted successfully"
|
||||
}, status: :ok
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "TransactionsController#destroy error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "Error: #{e.message}"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_transaction
|
||||
family = current_resource_owner.family
|
||||
@transaction = family.transactions.find(params[:id])
|
||||
@entry = @transaction.entry
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: {
|
||||
error: "not_found",
|
||||
message: "Transaction not found"
|
||||
}, status: :not_found
|
||||
end
|
||||
|
||||
def ensure_read_scope
|
||||
authorize_scope!(:read)
|
||||
end
|
||||
|
||||
def ensure_write_scope
|
||||
authorize_scope!(:write)
|
||||
end
|
||||
|
||||
def apply_filters(query)
|
||||
# Account filtering
|
||||
if params[:account_id].present?
|
||||
query = query.joins(:entry).where(entries: { account_id: params[:account_id] })
|
||||
end
|
||||
|
||||
if params[:account_ids].present?
|
||||
account_ids = Array(params[:account_ids])
|
||||
query = query.joins(:entry).where(entries: { account_id: account_ids })
|
||||
end
|
||||
|
||||
# Category filtering
|
||||
if params[:category_id].present?
|
||||
query = query.where(category_id: params[:category_id])
|
||||
end
|
||||
|
||||
if params[:category_ids].present?
|
||||
category_ids = Array(params[:category_ids])
|
||||
query = query.where(category_id: category_ids)
|
||||
end
|
||||
|
||||
# Merchant filtering
|
||||
if params[:merchant_id].present?
|
||||
query = query.where(merchant_id: params[:merchant_id])
|
||||
end
|
||||
|
||||
if params[:merchant_ids].present?
|
||||
merchant_ids = Array(params[:merchant_ids])
|
||||
query = query.where(merchant_id: merchant_ids)
|
||||
end
|
||||
|
||||
# Date range filtering
|
||||
if params[:start_date].present?
|
||||
query = query.joins(:entry).where("entries.date >= ?", Date.parse(params[:start_date]))
|
||||
end
|
||||
|
||||
if params[:end_date].present?
|
||||
query = query.joins(:entry).where("entries.date <= ?", Date.parse(params[:end_date]))
|
||||
end
|
||||
|
||||
# Amount filtering
|
||||
if params[:min_amount].present?
|
||||
min_amount = params[:min_amount].to_f
|
||||
query = query.joins(:entry).where("entries.amount >= ?", min_amount)
|
||||
end
|
||||
|
||||
if params[:max_amount].present?
|
||||
max_amount = params[:max_amount].to_f
|
||||
query = query.joins(:entry).where("entries.amount <= ?", max_amount)
|
||||
end
|
||||
|
||||
# Tag filtering
|
||||
if params[:tag_ids].present?
|
||||
tag_ids = Array(params[:tag_ids])
|
||||
query = query.joins(:tags).where(tags: { id: tag_ids })
|
||||
end
|
||||
|
||||
# Transaction type filtering (income/expense)
|
||||
if params[:type].present?
|
||||
case params[:type].downcase
|
||||
when "income"
|
||||
query = query.joins(:entry).where("entries.amount < 0")
|
||||
when "expense"
|
||||
query = query.joins(:entry).where("entries.amount > 0")
|
||||
end
|
||||
end
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
def apply_search(query)
|
||||
search_term = "%#{params[:search]}%"
|
||||
|
||||
query.joins(:entry)
|
||||
.left_joins(:merchant)
|
||||
.where(
|
||||
"entries.name ILIKE ? OR entries.notes ILIKE ? OR merchants.name ILIKE ?",
|
||||
search_term, search_term, search_term
|
||||
)
|
||||
end
|
||||
|
||||
def transaction_params
|
||||
params.require(:transaction).permit(
|
||||
:account_id, :date, :amount, :name, :description, :notes, :currency,
|
||||
:category_id, :merchant_id, :nature, tag_ids: []
|
||||
)
|
||||
end
|
||||
|
||||
def entry_params_for_create
|
||||
entry_params = {
|
||||
name: transaction_params[:name] || transaction_params[:description],
|
||||
date: transaction_params[:date],
|
||||
amount: calculate_signed_amount,
|
||||
currency: transaction_params[:currency] || current_resource_owner.family.currency,
|
||||
notes: transaction_params[:notes],
|
||||
entryable_type: "Transaction",
|
||||
entryable_attributes: {
|
||||
category_id: transaction_params[:category_id],
|
||||
merchant_id: transaction_params[:merchant_id],
|
||||
tag_ids: transaction_params[:tag_ids] || []
|
||||
}
|
||||
}
|
||||
|
||||
entry_params.compact
|
||||
end
|
||||
|
||||
def entry_params_for_update
|
||||
entry_params = {
|
||||
name: transaction_params[:name] || transaction_params[:description],
|
||||
date: transaction_params[:date],
|
||||
notes: transaction_params[:notes],
|
||||
entryable_attributes: {
|
||||
id: @entry.entryable_id,
|
||||
category_id: transaction_params[:category_id],
|
||||
merchant_id: transaction_params[:merchant_id],
|
||||
tag_ids: transaction_params[:tag_ids]
|
||||
}.compact_blank
|
||||
}
|
||||
|
||||
# Only update amount if provided
|
||||
if transaction_params[:amount].present?
|
||||
entry_params[:amount] = calculate_signed_amount
|
||||
end
|
||||
|
||||
entry_params.compact
|
||||
end
|
||||
|
||||
def calculate_signed_amount
|
||||
amount = transaction_params[:amount].to_f
|
||||
nature = transaction_params[:nature]
|
||||
|
||||
case nature&.downcase
|
||||
when "income", "inflow"
|
||||
-amount.abs # Income is negative
|
||||
when "expense", "outflow"
|
||||
amount.abs # Expense is positive
|
||||
else
|
||||
amount # Use as provided
|
||||
end
|
||||
end
|
||||
|
||||
def safe_page_param
|
||||
page = params[:page].to_i
|
||||
page > 0 ? page : 1
|
||||
end
|
||||
|
||||
def safe_per_page_param
|
||||
per_page = params[:per_page].to_i
|
||||
case per_page
|
||||
when 1..100
|
||||
per_page
|
||||
else
|
||||
25 # Default
|
||||
end
|
||||
end
|
||||
end
|
38
app/controllers/api/v1/usage_controller.rb
Normal file
38
app/controllers/api/v1/usage_controller.rb
Normal file
|
@ -0,0 +1,38 @@
|
|||
class Api::V1::UsageController < Api::V1::BaseController
|
||||
# GET /api/v1/usage
|
||||
def show
|
||||
return unless authorize_scope!(:read)
|
||||
|
||||
case @authentication_method
|
||||
when :api_key
|
||||
usage_info = @rate_limiter.usage_info
|
||||
render_json({
|
||||
api_key: {
|
||||
name: @api_key.name,
|
||||
scopes: @api_key.scopes,
|
||||
last_used_at: @api_key.last_used_at,
|
||||
created_at: @api_key.created_at
|
||||
},
|
||||
rate_limit: {
|
||||
tier: usage_info[:tier],
|
||||
limit: usage_info[:rate_limit],
|
||||
current_count: usage_info[:current_count],
|
||||
remaining: usage_info[:remaining],
|
||||
reset_in_seconds: usage_info[:reset_time],
|
||||
reset_at: Time.current + usage_info[:reset_time].seconds
|
||||
}
|
||||
})
|
||||
when :oauth
|
||||
# For OAuth, we don't track detailed usage yet, but we can return basic info
|
||||
render_json({
|
||||
authentication_method: "oauth",
|
||||
message: "Detailed usage tracking is available for API key authentication"
|
||||
})
|
||||
else
|
||||
render_json({
|
||||
error: "invalid_authentication_method",
|
||||
message: "Unable to determine usage information"
|
||||
}, status: :bad_request)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -25,6 +25,7 @@ module Onboardable
|
|||
return false if path.starts_with?("/subscription")
|
||||
return false if path.starts_with?("/onboarding")
|
||||
return false if path.starts_with?("/users")
|
||||
return false if path.starts_with?("/api") # Exclude API endpoints from onboarding redirects
|
||||
|
||||
[
|
||||
new_registration_path,
|
||||
|
|
61
app/controllers/settings/api_keys_controller.rb
Normal file
61
app/controllers/settings/api_keys_controller.rb
Normal file
|
@ -0,0 +1,61 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Settings::ApiKeysController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
before_action :set_api_key, only: [ :show, :destroy ]
|
||||
|
||||
def show
|
||||
@current_api_key = @api_key
|
||||
end
|
||||
|
||||
def new
|
||||
# Allow regeneration by not redirecting if user explicitly wants to create a new key
|
||||
# Only redirect if user stumbles onto new page without explicit intent
|
||||
redirect_to settings_api_key_path if Current.user.api_keys.active.exists? && !params[:regenerate]
|
||||
@api_key = ApiKey.new
|
||||
end
|
||||
|
||||
def create
|
||||
@plain_key = ApiKey.generate_secure_key
|
||||
@api_key = Current.user.api_keys.build(api_key_params)
|
||||
@api_key.key = @plain_key
|
||||
|
||||
# Temporarily revoke existing keys for validation to pass
|
||||
existing_keys = Current.user.api_keys.active
|
||||
existing_keys.each { |key| key.update_column(:revoked_at, Time.current) }
|
||||
|
||||
if @api_key.save
|
||||
flash[:notice] = "Your API key has been created successfully"
|
||||
redirect_to settings_api_key_path
|
||||
else
|
||||
# Restore existing keys if new key creation failed
|
||||
existing_keys.each { |key| key.update_column(:revoked_at, nil) }
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @api_key&.revoke!
|
||||
flash[:notice] = "API key has been revoked successfully"
|
||||
else
|
||||
flash[:alert] = "Failed to revoke API key"
|
||||
end
|
||||
redirect_to settings_api_key_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_api_key
|
||||
@api_key = Current.user.api_keys.active.first
|
||||
end
|
||||
|
||||
def api_key_params
|
||||
# Convert single scope value to array for storage
|
||||
permitted_params = params.require(:api_key).permit(:name, :scopes)
|
||||
if permitted_params[:scopes].present?
|
||||
permitted_params[:scopes] = [ permitted_params[:scopes] ]
|
||||
end
|
||||
permitted_params
|
||||
end
|
||||
end
|
|
@ -1,17 +1,27 @@
|
|||
class TradesController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
# Defaults to a buy trade
|
||||
def new
|
||||
@account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
@model = Current.family.entries.new(
|
||||
account: @account,
|
||||
currency: @account ? @account.currency : Current.family.currency,
|
||||
entryable: Trade.new
|
||||
)
|
||||
end
|
||||
|
||||
# Can create a trade, transaction (e.g. "fees"), or transfer (e.g. "withdrawal")
|
||||
def create
|
||||
@entry = build_entry
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
@model = Trade::CreateForm.new(create_params.merge(account: @account)).create
|
||||
|
||||
if @model.persisted?
|
||||
flash[:notice] = t("entries.create.success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
format.turbo_stream { stream_redirect_back_or_to account_path(@entry.account) }
|
||||
format.html { redirect_back_or_to account_path(@account) }
|
||||
format.turbo_stream { stream_redirect_back_or_to account_path(@account) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
|
@ -41,11 +51,6 @@ class TradesController < ApplicationController
|
|||
end
|
||||
|
||||
private
|
||||
def build_entry
|
||||
account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
||||
TradeBuilder.new(create_entry_params.merge(account: account))
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:entry).permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
|
@ -53,8 +58,8 @@ class TradesController < ApplicationController
|
|||
)
|
||||
end
|
||||
|
||||
def create_entry_params
|
||||
params.require(:entry).permit(
|
||||
def create_params
|
||||
params.require(:model).permit(
|
||||
:date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
)
|
||||
end
|
||||
|
|
|
@ -3,8 +3,6 @@ class TransactionsController < ApplicationController
|
|||
|
||||
before_action :store_params!, only: :index
|
||||
|
||||
require "digest/md5"
|
||||
|
||||
def new
|
||||
super
|
||||
@income_categories = Current.family.categories.incomes.alphabetically
|
||||
|
@ -13,95 +11,22 @@ class TransactionsController < ApplicationController
|
|||
|
||||
def index
|
||||
@q = search_params
|
||||
transactions_query = Current.family.transactions.active.search(@q)
|
||||
@search = Transaction::Search.new(Current.family, filters: @q)
|
||||
|
||||
set_focused_record(transactions_query, params[:focused_record_id], default_per_page: 50)
|
||||
base_scope = @search.transactions_scope
|
||||
.reverse_chronological
|
||||
.includes(
|
||||
{ entry: :account },
|
||||
:category, :merchant, :tags,
|
||||
:transfer_as_inflow, :transfer_as_outflow
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cache the expensive includes & pagination block so the DB work only
|
||||
# runs when either the query params change *or* any entry has been
|
||||
# updated for the current family.
|
||||
# ------------------------------------------------------------------
|
||||
@pagy, @transactions = pagy(base_scope, limit: per_page, params: ->(p) { p.except(:focused_record_id) })
|
||||
|
||||
latest_update_ts = Current.family.entries.maximum(:updated_at)&.utc&.to_i || 0
|
||||
|
||||
items_per_page = (params[:per_page].presence || default_params[:per_page]).to_i
|
||||
items_per_page = 1 if items_per_page <= 0
|
||||
|
||||
current_page = (params[:page].presence || default_params[:page]).to_i
|
||||
current_page = 1 if current_page <= 0
|
||||
|
||||
# Build a compact cache digest: sanitized filters + page info + a
|
||||
# token that changes on updates *or* deletions.
|
||||
entries_changed_token = [ latest_update_ts, Current.family.entries.count ].join(":")
|
||||
|
||||
digest_source = {
|
||||
q: @q, # processed & sanitised search params
|
||||
page: current_page, # requested page number
|
||||
per: items_per_page, # page size
|
||||
tok: entries_changed_token
|
||||
}.to_json
|
||||
|
||||
cache_key = Current.family.build_cache_key(
|
||||
"transactions_idx_#{Digest::MD5.hexdigest(digest_source)}"
|
||||
)
|
||||
|
||||
cache_data = Rails.cache.fetch(cache_key, expires_in: 30.minutes) do
|
||||
current_page_i = current_page
|
||||
|
||||
# Initial query
|
||||
offset = (current_page_i - 1) * items_per_page
|
||||
ids = transactions_query
|
||||
.reverse_chronological
|
||||
.limit(items_per_page)
|
||||
.offset(offset)
|
||||
.pluck(:id)
|
||||
|
||||
total_count = transactions_query.count
|
||||
|
||||
if ids.empty? && total_count.positive? && current_page_i > 1
|
||||
current_page_i = (total_count.to_f / items_per_page).ceil
|
||||
offset = (current_page_i - 1) * items_per_page
|
||||
|
||||
ids = transactions_query
|
||||
.reverse_chronological
|
||||
.limit(items_per_page)
|
||||
.offset(offset)
|
||||
.pluck(:id)
|
||||
end
|
||||
|
||||
{ ids: ids, total_count: total_count, current_page: current_page_i }
|
||||
# No performance penalty by default. Only runs queries if the record is set.
|
||||
if params[:focused_record_id].present?
|
||||
set_focused_record(base_scope, params[:focused_record_id], default_per_page: per_page)
|
||||
end
|
||||
|
||||
ids = cache_data[:ids]
|
||||
total_count = cache_data[:total_count]
|
||||
current_page = cache_data[:current_page]
|
||||
|
||||
# Build Pagy object (this part is cheap – done *after* potential
|
||||
# page fallback so the pagination UI reflects the adjusted page
|
||||
# number).
|
||||
@pagy = Pagy.new(
|
||||
count: total_count,
|
||||
page: current_page,
|
||||
items: items_per_page,
|
||||
params: ->(p) { p.except(:focused_record_id) }
|
||||
)
|
||||
|
||||
# Fetch the transactions in the cached order
|
||||
@transactions = Current.family.transactions
|
||||
.active
|
||||
.where(id: ids)
|
||||
.includes(
|
||||
{ entry: :account },
|
||||
:category, :merchant, :tags,
|
||||
transfer_as_outflow: { inflow_transaction: { entry: :account } },
|
||||
transfer_as_inflow: { outflow_transaction: { entry: :account } }
|
||||
)
|
||||
|
||||
# Preserve the order defined by `ids`
|
||||
@transactions = ids.map { |id| @transactions.detect { |t| t.id == id } }.compact
|
||||
|
||||
@totals = Current.family.income_statement.totals(transactions_scope: transactions_query)
|
||||
end
|
||||
|
||||
def clear_filter
|
||||
|
@ -124,6 +49,10 @@ class TransactionsController < ApplicationController
|
|||
end
|
||||
|
||||
updated_params["q"] = q_params.presence
|
||||
|
||||
# Add flag to indicate filters were explicitly cleared
|
||||
updated_params["filter_cleared"] = "1" if updated_params["q"].blank?
|
||||
|
||||
Current.session.update!(prev_transaction_page_params: updated_params)
|
||||
|
||||
redirect_to transactions_path(updated_params)
|
||||
|
@ -185,6 +114,10 @@ class TransactionsController < ApplicationController
|
|||
end
|
||||
|
||||
private
|
||||
def per_page
|
||||
params[:per_page].to_i.positive? ? params[:per_page].to_i : 50
|
||||
end
|
||||
|
||||
def needs_rule_notification?(transaction)
|
||||
return false if Current.user.rule_prompts_disabled
|
||||
|
||||
|
@ -200,7 +133,7 @@ class TransactionsController < ApplicationController
|
|||
def entry_params
|
||||
entry_params = params.require(:entry).permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
|
||||
entryable_attributes: [ :id, :category_id, :merchant_id, { tag_ids: [] } ]
|
||||
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, { tag_ids: [] } ]
|
||||
)
|
||||
|
||||
nature = entry_params.delete(:nature)
|
||||
|
@ -217,7 +150,8 @@ class TransactionsController < ApplicationController
|
|||
cleaned_params = params.fetch(:q, {})
|
||||
.permit(
|
||||
:start_date, :end_date, :search, :amount,
|
||||
:amount_operator, accounts: [], account_ids: [],
|
||||
:amount_operator, :active_accounts_only,
|
||||
accounts: [], account_ids: [],
|
||||
categories: [], merchants: [], types: [], tags: []
|
||||
)
|
||||
.to_h
|
||||
|
@ -225,35 +159,9 @@ class TransactionsController < ApplicationController
|
|||
|
||||
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Performance optimisation
|
||||
# -------------------------------------------------------------------
|
||||
# When a user lands on the Transactions page without an explicit date
|
||||
# filter, the previous behaviour queried *all* historical transactions
|
||||
# for the family. For large datasets this results in very expensive
|
||||
# SQL (as shown in Skylight) – particularly the aggregation queries
|
||||
# used for @totals. To keep the UI responsive while still showing a
|
||||
# sensible period of activity, we fall back to the user's preferred
|
||||
# default period (stored on User#default_period, defaulting to
|
||||
# "last_30_days") when **no** date filters have been supplied.
|
||||
#
|
||||
# This effectively changes the default view from "all-time" to a
|
||||
# rolling window, dramatically reducing the rows scanned / grouped in
|
||||
# Postgres without impacting the UX (the user can always clear the
|
||||
# filter).
|
||||
# -------------------------------------------------------------------
|
||||
if cleaned_params[:start_date].blank? && cleaned_params[:end_date].blank?
|
||||
period_key = Current.user&.default_period.presence || "last_30_days"
|
||||
|
||||
begin
|
||||
period = Period.from_key(period_key)
|
||||
cleaned_params[:start_date] = period.start_date
|
||||
cleaned_params[:end_date] = period.end_date
|
||||
rescue Period::InvalidKeyError
|
||||
# Fallback – should never happen but keeps things safe.
|
||||
cleaned_params[:start_date] = 30.days.ago.to_date
|
||||
cleaned_params[:end_date] = Date.current
|
||||
end
|
||||
# Only add default start_date if params are blank AND filters weren't explicitly cleared
|
||||
if cleaned_params.blank? && params[:filter_cleared].blank?
|
||||
cleaned_params[:start_date] = 30.days.ago.to_date
|
||||
end
|
||||
|
||||
cleaned_params
|
||||
|
@ -263,9 +171,9 @@ class TransactionsController < ApplicationController
|
|||
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]
|
||||
params_to_restore[:q] = stored_params["q"].presence || {}
|
||||
params_to_restore[:page] = stored_params["page"].presence || 1
|
||||
params_to_restore[:per_page] = stored_params["per_page"].presence || 50
|
||||
|
||||
redirect_to transactions_path(params_to_restore)
|
||||
else
|
||||
|
@ -286,12 +194,4 @@ class TransactionsController < ApplicationController
|
|||
def stored_params
|
||||
Current.session.prev_transaction_page_params
|
||||
end
|
||||
|
||||
def default_params
|
||||
{
|
||||
q: {},
|
||||
page: 1,
|
||||
per_page: 50
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,7 +8,12 @@ class TransferMatchesController < ApplicationController
|
|||
|
||||
def create
|
||||
@transfer = build_transfer
|
||||
@transfer.save!
|
||||
Transfer.transaction do
|
||||
@transfer.save!
|
||||
@transfer.outflow_transaction.update!(kind: Transfer.kind_for_account(@transfer.outflow_transaction.entry.account))
|
||||
@transfer.inflow_transaction.update!(kind: "funds_movement")
|
||||
end
|
||||
|
||||
@transfer.sync_account_later
|
||||
|
||||
redirect_back_or_to transactions_path, notice: "Transfer created"
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
class TransfersController < ApplicationController
|
||||
before_action :set_transfer, only: %i[destroy show update]
|
||||
include StreamExtensions
|
||||
|
||||
before_action :set_transfer, only: %i[show destroy update]
|
||||
|
||||
def new
|
||||
@transfer = Transfer.new
|
||||
|
@ -10,25 +12,19 @@ class TransfersController < ApplicationController
|
|||
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,
|
||||
@transfer = Transfer::Creator.new(
|
||||
family: Current.family,
|
||||
source_account_id: transfer_params[:from_account_id],
|
||||
destination_account_id: transfer_params[:to_account_id],
|
||||
date: transfer_params[:date],
|
||||
amount: transfer_params[:amount].to_d
|
||||
)
|
||||
|
||||
if @transfer.save
|
||||
@transfer.sync_account_later
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
).create
|
||||
|
||||
if @transfer.persisted?
|
||||
success_message = "Transfer created"
|
||||
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) }
|
||||
format.html { redirect_back_or_to transactions_path, notice: success_message }
|
||||
format.turbo_stream { stream_redirect_back_or_to transactions_path, notice: success_message }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
|
@ -54,9 +50,11 @@ class TransfersController < ApplicationController
|
|||
|
||||
private
|
||||
def set_transfer
|
||||
@transfer = Transfer.find(params[:id])
|
||||
|
||||
raise ActiveRecord::RecordNotFound unless @transfer.belongs_to_family?(Current.family)
|
||||
# Finds the transfer and ensures the family owns it
|
||||
@transfer = Transfer
|
||||
.where(id: params[:id])
|
||||
.where(inflow_transaction_id: Current.family.transactions.select(:id))
|
||||
.first
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
|
|
|
@ -110,7 +110,13 @@ module ApplicationHelper
|
|||
|
||||
private
|
||||
def calculate_total(item, money_method, negate)
|
||||
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }
|
||||
# Filter out transfer-type transactions from entries
|
||||
# Only Entry objects have entryable transactions, Account objects don't
|
||||
items = item.reject do |i|
|
||||
i.is_a?(Entry) &&
|
||||
i.entryable.is_a?(Transaction) &&
|
||||
i.entryable.transfer?
|
||||
end
|
||||
total = items.sum(&money_method)
|
||||
negate ? -total : total
|
||||
end
|
||||
|
|
|
@ -4,6 +4,7 @@ module SettingsHelper
|
|||
{ name: I18n.t("settings.settings_nav.preferences_label"), path: :settings_preferences_path },
|
||||
{ name: I18n.t("settings.settings_nav.security_label"), path: :settings_security_path },
|
||||
{ name: I18n.t("settings.settings_nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
|
||||
{ name: "API Key", path: :settings_api_key_path },
|
||||
{ name: I18n.t("settings.settings_nav.billing_label"), path: :settings_billing_path, condition: :not_self_hosted? },
|
||||
{ name: I18n.t("settings.settings_nav.accounts_label"), path: :accounts_path },
|
||||
{ name: I18n.t("settings.settings_nav.imports_label"), path: :imports_path },
|
||||
|
|
|
@ -11,6 +11,8 @@ class ImportMarketDataJob < ApplicationJob
|
|||
queue_as :scheduled
|
||||
|
||||
def perform(opts)
|
||||
return if Rails.env.development?
|
||||
|
||||
opts = opts.symbolize_keys
|
||||
mode = opts.fetch(:mode, :full)
|
||||
clear_cache = opts.fetch(:clear_cache, false)
|
||||
|
|
|
@ -2,6 +2,8 @@ class SecurityHealthCheckJob < ApplicationJob
|
|||
queue_as :scheduled
|
||||
|
||||
def perform
|
||||
return if Rails.env.development?
|
||||
|
||||
Security::HealthChecker.check_all
|
||||
end
|
||||
end
|
||||
|
|
|
@ -13,13 +13,6 @@ class Account::Syncer
|
|||
|
||||
def perform_post_sync
|
||||
account.family.auto_match_transfers!
|
||||
|
||||
# Warm IncomeStatement caches so subsequent requests are fast
|
||||
# TODO: this is a temporary solution to speed up pages. Long term we'll throw a materialized view / pre-computed table
|
||||
# in for family stats.
|
||||
income_statement = IncomeStatement.new(account.family)
|
||||
Rails.logger.info("Warming IncomeStatement caches")
|
||||
income_statement.warm_caches!
|
||||
end
|
||||
|
||||
private
|
||||
|
|
94
app/models/api_key.rb
Normal file
94
app/models/api_key.rb
Normal file
|
@ -0,0 +1,94 @@
|
|||
class ApiKey < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
# Use Rails built-in encryption for secure storage
|
||||
encrypts :display_key, deterministic: true
|
||||
|
||||
# Constants
|
||||
SOURCES = [ "web", "mobile" ].freeze
|
||||
|
||||
# Validations
|
||||
validates :display_key, presence: true, uniqueness: true
|
||||
validates :name, presence: true
|
||||
validates :scopes, presence: true
|
||||
validates :source, presence: true, inclusion: { in: SOURCES }
|
||||
validate :scopes_not_empty
|
||||
validate :one_active_key_per_user_per_source, on: :create
|
||||
|
||||
# Callbacks
|
||||
before_validation :set_display_key
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
|
||||
# Class methods
|
||||
def self.find_by_value(plain_key)
|
||||
return nil unless plain_key
|
||||
|
||||
# Find by encrypted display_key (deterministic encryption allows querying)
|
||||
find_by(display_key: plain_key)&.tap do |api_key|
|
||||
return api_key if api_key.active?
|
||||
end
|
||||
end
|
||||
|
||||
def self.generate_secure_key
|
||||
SecureRandom.hex(32)
|
||||
end
|
||||
|
||||
# Instance methods
|
||||
def active?
|
||||
!revoked? && !expired?
|
||||
end
|
||||
|
||||
def revoked?
|
||||
revoked_at.present?
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at.present? && expires_at < Time.current
|
||||
end
|
||||
|
||||
def key_matches?(plain_key)
|
||||
display_key == plain_key
|
||||
end
|
||||
|
||||
def revoke!
|
||||
update!(revoked_at: Time.current)
|
||||
end
|
||||
|
||||
def update_last_used!
|
||||
update_column(:last_used_at, Time.current)
|
||||
end
|
||||
|
||||
# Get the plain text API key for display (automatically decrypted by Rails)
|
||||
def plain_key
|
||||
display_key
|
||||
end
|
||||
|
||||
# Temporarily store the plain key for creation flow
|
||||
attr_accessor :key
|
||||
|
||||
private
|
||||
|
||||
def set_display_key
|
||||
if key.present?
|
||||
self.display_key = key
|
||||
end
|
||||
end
|
||||
|
||||
def scopes_not_empty
|
||||
if scopes.blank? || (scopes.is_a?(Array) && (scopes.empty? || scopes.all?(&:blank?)))
|
||||
errors.add(:scopes, "must include at least one permission")
|
||||
elsif scopes.is_a?(Array) && scopes.length > 1
|
||||
errors.add(:scopes, "can only have one permission level")
|
||||
elsif scopes.is_a?(Array) && !%w[read read_write].include?(scopes.first)
|
||||
errors.add(:scopes, "must be either 'read' or 'read_write'")
|
||||
end
|
||||
end
|
||||
|
||||
def one_active_key_per_user_per_source
|
||||
if user&.api_keys&.active&.where(source: source)&.where&.not(id: id)&.exists?
|
||||
errors.add(:user, "can only have one active API key per source (#{source})")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -163,7 +163,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
|
|||
category: txn.category&.name,
|
||||
merchant: txn.merchant&.name,
|
||||
tags: txn.tags.map(&:name),
|
||||
is_transfer: txn.transfer.present?
|
||||
is_transfer: txn.transfer?
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
@ -91,6 +91,7 @@ class Family < ApplicationRecord
|
|||
entries.order(:date).first&.date || Date.current
|
||||
end
|
||||
|
||||
# Used for invalidating family / balance sheet related aggregation queries
|
||||
def build_cache_key(key, invalidate_on_data_updates: false)
|
||||
# Our data sync process updates this timestamp whenever any family account successfully completes a data update.
|
||||
# By including it in the cache key, we can expire caches every time family account data changes.
|
||||
|
@ -103,6 +104,14 @@ class Family < ApplicationRecord
|
|||
].compact.join("_")
|
||||
end
|
||||
|
||||
# Used for invalidating entry related aggregation queries
|
||||
def entries_cache_version
|
||||
@entries_cache_version ||= begin
|
||||
ts = entries.maximum(:updated_at)
|
||||
ts.present? ? ts.to_i : 0
|
||||
end
|
||||
end
|
||||
|
||||
def self_hoster?
|
||||
Rails.application.config.app_mode.self_hosted?
|
||||
end
|
||||
|
|
|
@ -65,6 +65,9 @@ module Family::AutoTransferMatchable
|
|||
outflow_transaction_id: match.outflow_transaction_id,
|
||||
)
|
||||
|
||||
Transaction.find(match.inflow_transaction_id).update!(kind: "funds_movement")
|
||||
Transaction.find(match.outflow_transaction_id).update!(kind: Transfer.kind_for_account(Transaction.find(match.outflow_transaction_id).entry.account))
|
||||
|
||||
used_transaction_ids << match.inflow_transaction_id
|
||||
used_transaction_ids << match.outflow_transaction_id
|
||||
end
|
||||
|
|
|
@ -20,8 +20,7 @@ class IncomeStatement
|
|||
ScopeTotals.new(
|
||||
transactions_count: result.sum(&:transactions_count),
|
||||
income_money: Money.new(total_income, family.currency),
|
||||
expense_money: Money.new(total_expense, family.currency),
|
||||
missing_exchange_rates?: result.any?(&:missing_exchange_rates?)
|
||||
expense_money: Money.new(total_expense, family.currency)
|
||||
)
|
||||
end
|
||||
|
||||
|
@ -53,16 +52,9 @@ class IncomeStatement
|
|||
family_stats(interval: interval).find { |stat| stat.classification == "income" }&.median || 0
|
||||
end
|
||||
|
||||
def warm_caches!(interval: "month")
|
||||
totals
|
||||
family_stats(interval: interval)
|
||||
category_stats(interval: interval)
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money, :missing_exchange_rates?)
|
||||
PeriodTotal = Data.define(:classification, :total, :currency, :missing_exchange_rates?, :category_totals)
|
||||
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money)
|
||||
PeriodTotal = Data.define(:classification, :total, :currency, :category_totals)
|
||||
CategoryTotal = Data.define(:category, :total, :currency, :weight)
|
||||
|
||||
def categories
|
||||
|
@ -102,7 +94,6 @@ class IncomeStatement
|
|||
classification: classification,
|
||||
total: category_totals.reject { |ct| ct.category.subcategory? }.sum(&:total),
|
||||
currency: family.currency,
|
||||
missing_exchange_rates?: totals.any?(&:missing_exchange_rates?),
|
||||
category_totals: category_totals
|
||||
)
|
||||
end
|
||||
|
@ -110,14 +101,14 @@ class IncomeStatement
|
|||
def family_stats(interval: "month")
|
||||
@family_stats ||= {}
|
||||
@family_stats[interval] ||= Rails.cache.fetch([
|
||||
"income_statement", "family_stats", family.id, interval, entries_cache_version
|
||||
"income_statement", "family_stats", family.id, interval, family.entries_cache_version
|
||||
]) { FamilyStats.new(family, interval:).call }
|
||||
end
|
||||
|
||||
def category_stats(interval: "month")
|
||||
@category_stats ||= {}
|
||||
@category_stats[interval] ||= Rails.cache.fetch([
|
||||
"income_statement", "category_stats", family.id, interval, entries_cache_version
|
||||
"income_statement", "category_stats", family.id, interval, family.entries_cache_version
|
||||
]) { CategoryStats.new(family, interval:).call }
|
||||
end
|
||||
|
||||
|
@ -125,24 +116,11 @@ class IncomeStatement
|
|||
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
|
||||
|
||||
Rails.cache.fetch([
|
||||
"income_statement", "totals_query", family.id, sql_hash, entries_cache_version
|
||||
"income_statement", "totals_query", family.id, sql_hash, family.entries_cache_version
|
||||
]) { Totals.new(family, transactions_scope: transactions_scope).call }
|
||||
end
|
||||
|
||||
def monetizable_currency
|
||||
family.currency
|
||||
end
|
||||
|
||||
# Returns a monotonically increasing integer based on the most recent
|
||||
# update to any Entry that belongs to the family. Incorporated into cache
|
||||
# keys so they expire automatically on data changes.
|
||||
def entries_cache_version
|
||||
@entries_cache_version ||= begin
|
||||
ts = Entry.joins(:account)
|
||||
.where(accounts: { family_id: family.id })
|
||||
.maximum(:updated_at)
|
||||
|
||||
ts.present? ? ts.to_i : 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
module IncomeStatement::BaseQuery
|
||||
private
|
||||
def base_query_sql(family:, interval:, transactions_scope:)
|
||||
sql = <<~SQL
|
||||
SELECT
|
||||
c.id as category_id,
|
||||
c.parent_id as parent_category_id,
|
||||
date_trunc(:interval, ae.date) as date,
|
||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||
SUM(ae.amount * COALESCE(er.rate, 1)) as total,
|
||||
COUNT(ae.id) as transactions_count,
|
||||
BOOL_OR(ae.currency <> :target_currency AND er.rate IS NULL) as missing_exchange_rates
|
||||
FROM (#{transactions_scope.to_sql}) at
|
||||
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
|
||||
LEFT JOIN categories c ON c.id = at.category_id
|
||||
LEFT JOIN (
|
||||
SELECT t.*, t.id as transfer_id, a.accountable_type
|
||||
FROM transfers t
|
||||
JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id
|
||||
AND ae.entryable_type = 'Transaction'
|
||||
JOIN accounts a ON a.id = ae.account_id
|
||||
) transfer_info ON (
|
||||
transfer_info.inflow_transaction_id = at.id OR
|
||||
transfer_info.outflow_transaction_id = at.id
|
||||
)
|
||||
LEFT JOIN exchange_rates er ON (
|
||||
er.date = ae.date AND
|
||||
er.from_currency = ae.currency AND
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE (
|
||||
transfer_info.transfer_id IS NULL OR
|
||||
(ae.amount > 0 AND transfer_info.accountable_type = 'Loan')
|
||||
)
|
||||
GROUP BY 1, 2, 3, 4
|
||||
SQL
|
||||
|
||||
ActiveRecord::Base.sanitize_sql_array([
|
||||
sql,
|
||||
{ target_currency: family.currency, interval: interval }
|
||||
])
|
||||
end
|
||||
end
|
|
@ -1,40 +1,62 @@
|
|||
class IncomeStatement::CategoryStats
|
||||
include IncomeStatement::BaseQuery
|
||||
|
||||
def initialize(family, interval: "month")
|
||||
@family = family
|
||||
@interval = interval
|
||||
end
|
||||
|
||||
def call
|
||||
ActiveRecord::Base.connection.select_all(query_sql).map do |row|
|
||||
ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|
|
||||
StatRow.new(
|
||||
category_id: row["category_id"],
|
||||
classification: row["classification"],
|
||||
median: row["median"],
|
||||
avg: row["avg"],
|
||||
missing_exchange_rates?: row["missing_exchange_rates"]
|
||||
avg: row["avg"]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
StatRow = Data.define(:category_id, :classification, :median, :avg, :missing_exchange_rates?)
|
||||
StatRow = Data.define(:category_id, :classification, :median, :avg)
|
||||
|
||||
def sanitized_query_sql
|
||||
ActiveRecord::Base.sanitize_sql_array([
|
||||
query_sql,
|
||||
{
|
||||
target_currency: @family.currency,
|
||||
interval: @interval,
|
||||
family_id: @family.id
|
||||
}
|
||||
])
|
||||
end
|
||||
|
||||
def query_sql
|
||||
base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
|
||||
|
||||
<<~SQL
|
||||
WITH base_totals AS (
|
||||
#{base_sql}
|
||||
WITH period_totals AS (
|
||||
SELECT
|
||||
c.id as category_id,
|
||||
date_trunc(:interval, ae.date) as period,
|
||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||
SUM(ae.amount * COALESCE(er.rate, 1)) as total
|
||||
FROM transactions t
|
||||
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction'
|
||||
JOIN accounts a ON a.id = ae.account_id
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
LEFT JOIN exchange_rates er ON (
|
||||
er.date = ae.date AND
|
||||
er.from_currency = ae.currency AND
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE a.family_id = :family_id
|
||||
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
AND ae.excluded = false
|
||||
GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
)
|
||||
SELECT
|
||||
category_id,
|
||||
classification,
|
||||
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
|
||||
ABS(AVG(total)) as avg,
|
||||
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
|
||||
FROM base_totals
|
||||
category_id,
|
||||
classification,
|
||||
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
|
||||
ABS(AVG(total)) as avg
|
||||
FROM period_totals
|
||||
GROUP BY category_id, classification;
|
||||
SQL
|
||||
end
|
||||
|
|
|
@ -1,46 +1,58 @@
|
|||
class IncomeStatement::FamilyStats
|
||||
include IncomeStatement::BaseQuery
|
||||
|
||||
def initialize(family, interval: "month")
|
||||
@family = family
|
||||
@interval = interval
|
||||
end
|
||||
|
||||
def call
|
||||
ActiveRecord::Base.connection.select_all(query_sql).map do |row|
|
||||
ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|
|
||||
StatRow.new(
|
||||
classification: row["classification"],
|
||||
median: row["median"],
|
||||
avg: row["avg"],
|
||||
missing_exchange_rates?: row["missing_exchange_rates"]
|
||||
avg: row["avg"]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
StatRow = Data.define(:classification, :median, :avg, :missing_exchange_rates?)
|
||||
StatRow = Data.define(:classification, :median, :avg)
|
||||
|
||||
def sanitized_query_sql
|
||||
ActiveRecord::Base.sanitize_sql_array([
|
||||
query_sql,
|
||||
{
|
||||
target_currency: @family.currency,
|
||||
interval: @interval,
|
||||
family_id: @family.id
|
||||
}
|
||||
])
|
||||
end
|
||||
|
||||
def query_sql
|
||||
base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
|
||||
|
||||
<<~SQL
|
||||
WITH base_totals AS (
|
||||
#{base_sql}
|
||||
), aggregated_totals AS (
|
||||
WITH period_totals AS (
|
||||
SELECT
|
||||
date,
|
||||
classification,
|
||||
SUM(total) as total,
|
||||
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
|
||||
FROM base_totals
|
||||
GROUP BY date, classification
|
||||
date_trunc(:interval, ae.date) as period,
|
||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||
SUM(ae.amount * COALESCE(er.rate, 1)) as total
|
||||
FROM transactions t
|
||||
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction'
|
||||
JOIN accounts a ON a.id = ae.account_id
|
||||
LEFT JOIN exchange_rates er ON (
|
||||
er.date = ae.date AND
|
||||
er.from_currency = ae.currency AND
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE a.family_id = :family_id
|
||||
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
AND ae.excluded = false
|
||||
GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
)
|
||||
SELECT
|
||||
classification,
|
||||
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
|
||||
ABS(AVG(total)) as avg,
|
||||
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
|
||||
FROM aggregated_totals
|
||||
classification,
|
||||
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
|
||||
ABS(AVG(total)) as avg
|
||||
FROM period_totals
|
||||
GROUP BY classification;
|
||||
SQL
|
||||
end
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
class IncomeStatement::Totals
|
||||
include IncomeStatement::BaseQuery
|
||||
|
||||
def initialize(family, transactions_scope:)
|
||||
@family = family
|
||||
@transactions_scope = transactions_scope
|
||||
|
@ -13,31 +11,48 @@ class IncomeStatement::Totals
|
|||
category_id: row["category_id"],
|
||||
classification: row["classification"],
|
||||
total: row["total"],
|
||||
transactions_count: row["transactions_count"],
|
||||
missing_exchange_rates?: row["missing_exchange_rates"]
|
||||
transactions_count: row["transactions_count"]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :missing_exchange_rates?)
|
||||
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count)
|
||||
|
||||
def query_sql
|
||||
base_sql = base_query_sql(family: @family, interval: "day", transactions_scope: @transactions_scope)
|
||||
ActiveRecord::Base.sanitize_sql_array([
|
||||
optimized_query_sql,
|
||||
sql_params
|
||||
])
|
||||
end
|
||||
|
||||
# OPTIMIZED: Direct SUM aggregation without unnecessary time bucketing
|
||||
# Eliminates CTE and intermediate date grouping for maximum performance
|
||||
def optimized_query_sql
|
||||
<<~SQL
|
||||
WITH base_totals AS (
|
||||
#{base_sql}
|
||||
)
|
||||
SELECT
|
||||
parent_category_id,
|
||||
category_id,
|
||||
classification,
|
||||
ABS(SUM(total)) as total,
|
||||
BOOL_OR(missing_exchange_rates) as missing_exchange_rates,
|
||||
SUM(transactions_count) as transactions_count
|
||||
FROM base_totals
|
||||
GROUP BY 1, 2, 3;
|
||||
c.id as category_id,
|
||||
c.parent_id as parent_category_id,
|
||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
|
||||
COUNT(ae.id) as transactions_count
|
||||
FROM (#{@transactions_scope.to_sql}) at
|
||||
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
|
||||
LEFT JOIN categories c ON c.id = at.category_id
|
||||
LEFT JOIN exchange_rates er ON (
|
||||
er.date = ae.date AND
|
||||
er.from_currency = ae.currency AND
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
AND ae.excluded = false
|
||||
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
|
||||
SQL
|
||||
end
|
||||
|
||||
def sql_params
|
||||
{
|
||||
target_currency: @family.currency
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
55
app/models/mobile_device.rb
Normal file
55
app/models/mobile_device.rb
Normal file
|
@ -0,0 +1,55 @@
|
|||
class MobileDevice < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :oauth_application, class_name: "Doorkeeper::Application", optional: true
|
||||
|
||||
validates :device_id, presence: true, uniqueness: { scope: :user_id }
|
||||
validates :device_name, presence: true
|
||||
validates :device_type, presence: true, inclusion: { in: %w[ios android] }
|
||||
|
||||
before_validation :set_last_seen_at, on: :create
|
||||
|
||||
scope :active, -> { where("last_seen_at > ?", 90.days.ago) }
|
||||
|
||||
def active?
|
||||
last_seen_at > 90.days.ago
|
||||
end
|
||||
|
||||
def update_last_seen!
|
||||
update_column(:last_seen_at, Time.current)
|
||||
end
|
||||
|
||||
def create_oauth_application!
|
||||
return oauth_application if oauth_application.present?
|
||||
|
||||
app = Doorkeeper::Application.create!(
|
||||
name: "Mobile App - #{device_id}",
|
||||
redirect_uri: "maybe://oauth/callback", # Custom scheme for mobile
|
||||
scopes: "read_write", # Use the configured scope
|
||||
confidential: false # Public client for mobile
|
||||
)
|
||||
|
||||
# Store the association
|
||||
update!(oauth_application: app)
|
||||
app
|
||||
end
|
||||
|
||||
def active_tokens
|
||||
return Doorkeeper::AccessToken.none unless oauth_application
|
||||
|
||||
Doorkeeper::AccessToken
|
||||
.where(application: oauth_application)
|
||||
.where(resource_owner_id: user_id)
|
||||
.where(revoked_at: nil)
|
||||
.where("expires_in IS NULL OR created_at + expires_in * interval '1 second' > ?", Time.current)
|
||||
end
|
||||
|
||||
def revoke_all_tokens!
|
||||
active_tokens.update_all(revoked_at: Time.current)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_last_seen_at
|
||||
self.last_seen_at ||= Time.current
|
||||
end
|
||||
end
|
|
@ -53,7 +53,7 @@ module Security::Provided
|
|||
|
||||
price = response.data
|
||||
Security::Price.find_or_create_by!(
|
||||
security_id: price.security.id,
|
||||
security_id: self.id,
|
||||
date: price.date,
|
||||
price: price.price,
|
||||
currency: price.currency
|
||||
|
|
113
app/models/trade/create_form.rb
Normal file
113
app/models/trade/create_form.rb
Normal file
|
@ -0,0 +1,113 @@
|
|||
class Trade::CreateForm
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
|
||||
# Either creates a trade, transaction, or transfer based on type
|
||||
# Returns the model, regardless of success or failure
|
||||
def create
|
||||
case type
|
||||
when "buy", "sell"
|
||||
create_trade
|
||||
when "interest"
|
||||
create_interest_income
|
||||
when "deposit", "withdrawal"
|
||||
create_transfer
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
|
||||
def security
|
||||
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
|
||||
|
||||
Security::Resolver.new(
|
||||
ticker_symbol,
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
).resolve
|
||||
end
|
||||
|
||||
def create_trade
|
||||
prefix = type == "sell" ? "Sell " : "Buy "
|
||||
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
|
||||
signed_qty = type == "sell" ? -qty.to_d : qty.to_d
|
||||
signed_amount = signed_qty * price.to_d
|
||||
|
||||
trade_entry = account.entries.new(
|
||||
name: trade_name,
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Trade.new(
|
||||
qty: signed_qty,
|
||||
price: price,
|
||||
currency: currency,
|
||||
security: security
|
||||
)
|
||||
)
|
||||
|
||||
if trade_entry.save
|
||||
trade_entry.lock_saved_attributes!
|
||||
account.sync_later
|
||||
end
|
||||
|
||||
trade_entry
|
||||
end
|
||||
|
||||
def create_interest_income
|
||||
signed_amount = amount.to_d * -1
|
||||
|
||||
entry = account.entries.build(
|
||||
name: "Interest payment",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
if entry.save
|
||||
entry.lock_saved_attributes!
|
||||
account.sync_later
|
||||
end
|
||||
|
||||
entry
|
||||
end
|
||||
|
||||
def create_transfer
|
||||
if transfer_account_id.present?
|
||||
from_account_id = type == "withdrawal" ? account.id : transfer_account_id
|
||||
to_account_id = type == "withdrawal" ? transfer_account_id : account.id
|
||||
|
||||
Transfer::Creator.new(
|
||||
family: account.family,
|
||||
source_account_id: from_account_id,
|
||||
destination_account_id: to_account_id,
|
||||
date: date,
|
||||
amount: amount
|
||||
).create
|
||||
else
|
||||
create_unlinked_transfer
|
||||
end
|
||||
end
|
||||
|
||||
# If user doesn't provide the reciprocal account, it's a regular transaction
|
||||
def create_unlinked_transfer
|
||||
signed_amount = type == "deposit" ? amount.to_d * -1 : amount.to_d
|
||||
|
||||
entry = account.entries.build(
|
||||
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
if entry.save
|
||||
entry.lock_saved_attributes!
|
||||
account.sync_later
|
||||
end
|
||||
|
||||
entry
|
||||
end
|
||||
end
|
|
@ -1,137 +0,0 @@
|
|||
class TradeBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
|
||||
attr_reader :buildable
|
||||
|
||||
def initialize(attributes = {})
|
||||
super
|
||||
@buildable = set_buildable
|
||||
end
|
||||
|
||||
def save
|
||||
buildable.save
|
||||
end
|
||||
|
||||
def lock_saved_attributes!
|
||||
if buildable.is_a?(Transfer)
|
||||
buildable.inflow_transaction.entry.lock_saved_attributes!
|
||||
buildable.outflow_transaction.entry.lock_saved_attributes!
|
||||
else
|
||||
buildable.lock_saved_attributes!
|
||||
end
|
||||
end
|
||||
|
||||
def entryable
|
||||
return nil if buildable.is_a?(Transfer)
|
||||
|
||||
buildable.entryable
|
||||
end
|
||||
|
||||
def errors
|
||||
buildable.errors
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
buildable.sync_account_later
|
||||
end
|
||||
|
||||
private
|
||||
def set_buildable
|
||||
case type
|
||||
when "buy", "sell"
|
||||
build_trade
|
||||
when "deposit", "withdrawal"
|
||||
build_transfer
|
||||
when "interest"
|
||||
build_interest
|
||||
else
|
||||
raise "Unknown trade type: #{type}"
|
||||
end
|
||||
end
|
||||
|
||||
def build_trade
|
||||
prefix = type == "sell" ? "Sell " : "Buy "
|
||||
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
|
||||
|
||||
account.entries.new(
|
||||
name: trade_name,
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Trade.new(
|
||||
qty: signed_qty,
|
||||
price: price,
|
||||
currency: currency,
|
||||
security: security
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def build_transfer
|
||||
transfer_account = family.accounts.find(transfer_account_id) if transfer_account_id.present?
|
||||
|
||||
if transfer_account
|
||||
from_account = type == "withdrawal" ? account : transfer_account
|
||||
to_account = type == "withdrawal" ? transfer_account : account
|
||||
|
||||
Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
date: date,
|
||||
amount: signed_amount
|
||||
)
|
||||
else
|
||||
account.entries.build(
|
||||
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def build_interest
|
||||
account.entries.build(
|
||||
name: "Interest payment",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
end
|
||||
|
||||
def signed_qty
|
||||
return nil unless type.in?([ "buy", "sell" ])
|
||||
|
||||
type == "sell" ? -qty.to_d : qty.to_d
|
||||
end
|
||||
|
||||
def signed_amount
|
||||
case type
|
||||
when "buy", "sell"
|
||||
signed_qty * price.to_d
|
||||
when "deposit", "withdrawal"
|
||||
type == "deposit" ? -amount.to_d : amount.to_d
|
||||
when "interest"
|
||||
amount.to_d * -1
|
||||
end
|
||||
end
|
||||
|
||||
def family
|
||||
account.family
|
||||
end
|
||||
|
||||
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
|
||||
def security
|
||||
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
|
||||
|
||||
Security::Resolver.new(
|
||||
ticker_symbol,
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
).resolve
|
||||
end
|
||||
end
|
|
@ -9,10 +9,17 @@ class Transaction < ApplicationRecord
|
|||
|
||||
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
Search.new(params).build_query(all)
|
||||
end
|
||||
enum :kind, {
|
||||
standard: "standard", # A regular transaction, included in budget analytics
|
||||
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
|
||||
cc_payment: "cc_payment", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions)
|
||||
loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets
|
||||
one_time: "one_time" # A one-time expense/income, excluded from budget analytics
|
||||
}
|
||||
|
||||
# Overarching grouping method for all transfer-type transactions
|
||||
def transfer?
|
||||
funds_movement? || cc_payment? || loan_payment?
|
||||
end
|
||||
|
||||
def set_category!(category)
|
||||
|
|
|
@ -13,45 +13,87 @@ class Transaction::Search
|
|||
attribute :categories, array: true
|
||||
attribute :merchants, array: true
|
||||
attribute :tags, array: true
|
||||
attribute :active_accounts_only, :boolean, default: true
|
||||
|
||||
def build_query(scope)
|
||||
query = scope.joins(entry: :account)
|
||||
.joins(transfer_join)
|
||||
attr_reader :family
|
||||
|
||||
query = apply_category_filter(query, categories)
|
||||
query = apply_type_filter(query, types)
|
||||
query = apply_merchant_filter(query, merchants)
|
||||
query = apply_tag_filter(query, tags)
|
||||
query = EntrySearch.apply_search_filter(query, search)
|
||||
query = EntrySearch.apply_date_filters(query, start_date, end_date)
|
||||
query = EntrySearch.apply_amount_filter(query, amount, amount_operator)
|
||||
query = EntrySearch.apply_accounts_filter(query, accounts, account_ids)
|
||||
def initialize(family, filters: {})
|
||||
@family = family
|
||||
super(filters)
|
||||
end
|
||||
|
||||
query
|
||||
def transactions_scope
|
||||
@transactions_scope ||= begin
|
||||
# This already joins entries + accounts. To avoid expensive double-joins, don't join them again (causes full table scan)
|
||||
query = family.transactions
|
||||
|
||||
query = apply_active_accounts_filter(query, active_accounts_only)
|
||||
query = apply_category_filter(query, categories)
|
||||
query = apply_type_filter(query, types)
|
||||
query = apply_merchant_filter(query, merchants)
|
||||
query = apply_tag_filter(query, tags)
|
||||
query = EntrySearch.apply_search_filter(query, search)
|
||||
query = EntrySearch.apply_date_filters(query, start_date, end_date)
|
||||
query = EntrySearch.apply_amount_filter(query, amount, amount_operator)
|
||||
query = EntrySearch.apply_accounts_filter(query, accounts, account_ids)
|
||||
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
# Computes totals for the specific search
|
||||
def totals
|
||||
@totals ||= begin
|
||||
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
|
||||
result = transactions_scope
|
||||
.select(
|
||||
"COALESCE(SUM(CASE WHEN entries.amount >= 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
|
||||
"COALESCE(SUM(CASE WHEN entries.amount < 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
|
||||
"COUNT(entries.id) as transactions_count"
|
||||
)
|
||||
.joins(
|
||||
ActiveRecord::Base.sanitize_sql_array([
|
||||
"LEFT JOIN exchange_rates er ON (er.date = entries.date AND er.from_currency = entries.currency AND er.to_currency = ?)",
|
||||
family.currency
|
||||
])
|
||||
)
|
||||
.take
|
||||
|
||||
Totals.new(
|
||||
count: result.transactions_count.to_i,
|
||||
income_money: Money.new(result.income_total.to_i, family.currency),
|
||||
expense_money: Money.new(result.expense_total.to_i, family.currency)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def cache_key_base
|
||||
[
|
||||
family.id,
|
||||
Digest::SHA256.hexdigest(attributes.sort.to_h.to_json), # cached by filters
|
||||
family.entries_cache_version
|
||||
].join("/")
|
||||
end
|
||||
|
||||
private
|
||||
def transfer_join
|
||||
<<~SQL
|
||||
LEFT JOIN (
|
||||
SELECT t.*, t.id as transfer_id, a.accountable_type
|
||||
FROM transfers t
|
||||
JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id
|
||||
AND ae.entryable_type = 'Transaction'
|
||||
JOIN accounts a ON a.id = ae.account_id
|
||||
) transfer_info ON (
|
||||
transfer_info.inflow_transaction_id = transactions.id OR
|
||||
transfer_info.outflow_transaction_id = transactions.id
|
||||
)
|
||||
SQL
|
||||
Totals = Data.define(:count, :income_money, :expense_money)
|
||||
|
||||
def apply_active_accounts_filter(query, active_accounts_only_filter)
|
||||
if active_accounts_only_filter
|
||||
query.where(accounts: { is_active: true })
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def apply_category_filter(query, categories)
|
||||
return query unless categories.present?
|
||||
|
||||
query = query.left_joins(:category).where(
|
||||
"categories.name IN (?) OR (
|
||||
categories.id IS NULL AND (transfer_info.transfer_id IS NULL OR transfer_info.accountable_type = 'Loan')
|
||||
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))
|
||||
)",
|
||||
categories
|
||||
)
|
||||
|
@ -67,7 +109,7 @@ class Transaction::Search
|
|||
return query unless types.present?
|
||||
return query if types.sort == [ "expense", "income", "transfer" ]
|
||||
|
||||
transfer_condition = "transfer_info.transfer_id IS NOT NULL"
|
||||
transfer_condition = "transactions.kind IN ('funds_movement', 'cc_payment', 'loan_payment')"
|
||||
expense_condition = "entries.amount >= 0"
|
||||
income_condition = "entries.amount <= 0"
|
||||
|
||||
|
|
|
@ -14,10 +14,6 @@ module Transaction::Transferable
|
|||
transfer_as_inflow || transfer_as_outflow
|
||||
end
|
||||
|
||||
def transfer?
|
||||
transfer.present?
|
||||
end
|
||||
|
||||
def transfer_match_candidates
|
||||
candidates_scope = if self.entry.amount.negative?
|
||||
family_matches_scope.where("inflow_candidates.entryable_id = ?", self.id)
|
||||
|
|
|
@ -13,34 +13,14 @@ class Transfer < ApplicationRecord
|
|||
validate :transfer_has_same_family
|
||||
|
||||
class << self
|
||||
def from_accounts(from_account:, to_account:, date:, amount:)
|
||||
# Attempt to convert the amount to the to_account's currency.
|
||||
# If the conversion fails, use the original amount.
|
||||
converted_amount = begin
|
||||
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
|
||||
rescue Money::ConversionError
|
||||
Money.new(amount.abs, from_account.currency)
|
||||
def kind_for_account(account)
|
||||
if account.loan?
|
||||
"loan_payment"
|
||||
elsif account.liability?
|
||||
"cc_payment"
|
||||
else
|
||||
"funds_movement"
|
||||
end
|
||||
|
||||
new(
|
||||
inflow_transaction: Transaction.new(
|
||||
entry: to_account.entries.build(
|
||||
amount: converted_amount.amount.abs * -1,
|
||||
currency: converted_amount.currency.iso_code,
|
||||
date: date,
|
||||
name: "Transfer from #{from_account.name}",
|
||||
)
|
||||
),
|
||||
outflow_transaction: Transaction.new(
|
||||
entry: from_account.entries.build(
|
||||
amount: amount.abs,
|
||||
currency: from_account.currency,
|
||||
date: date,
|
||||
name: "Transfer to #{to_account.name}",
|
||||
)
|
||||
),
|
||||
status: "confirmed"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -51,19 +31,28 @@ class Transfer < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
# Once transfer is destroyed, we need to mark the denormalized kind fields on the transactions
|
||||
def destroy!
|
||||
Transfer.transaction do
|
||||
inflow_transaction.update!(kind: "standard")
|
||||
outflow_transaction.update!(kind: "standard")
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def confirm!
|
||||
update!(status: "confirmed")
|
||||
end
|
||||
|
||||
def date
|
||||
inflow_transaction.entry.date
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
inflow_transaction&.entry&.sync_account_later
|
||||
outflow_transaction&.entry&.sync_account_later
|
||||
end
|
||||
|
||||
def belongs_to_family?(family)
|
||||
family.transactions.include?(inflow_transaction)
|
||||
end
|
||||
|
||||
def to_account
|
||||
inflow_transaction&.entry&.account
|
||||
end
|
||||
|
@ -89,6 +78,24 @@ class Transfer < ApplicationRecord
|
|||
to_account&.liability?
|
||||
end
|
||||
|
||||
def loan_payment?
|
||||
outflow_transaction&.kind == "loan_payment"
|
||||
end
|
||||
|
||||
def liability_payment?
|
||||
outflow_transaction&.kind == "cc_payment"
|
||||
end
|
||||
|
||||
def regular_transfer?
|
||||
outflow_transaction&.kind == "funds_movement"
|
||||
end
|
||||
|
||||
def transfer_type
|
||||
return "loan_payment" if loan_payment?
|
||||
return "liability_payment" if liability_payment?
|
||||
"transfer"
|
||||
end
|
||||
|
||||
def categorizable?
|
||||
to_account&.accountable_type == "Loan"
|
||||
end
|
||||
|
|
85
app/models/transfer/creator.rb
Normal file
85
app/models/transfer/creator.rb
Normal file
|
@ -0,0 +1,85 @@
|
|||
class Transfer::Creator
|
||||
def initialize(family:, source_account_id:, destination_account_id:, date:, amount:)
|
||||
@family = family
|
||||
@source_account = family.accounts.find(source_account_id) # early throw if not found
|
||||
@destination_account = family.accounts.find(destination_account_id) # early throw if not found
|
||||
@date = date
|
||||
@amount = amount.to_d
|
||||
end
|
||||
|
||||
def create
|
||||
transfer = Transfer.new(
|
||||
inflow_transaction: inflow_transaction,
|
||||
outflow_transaction: outflow_transaction,
|
||||
status: "confirmed"
|
||||
)
|
||||
|
||||
if transfer.save
|
||||
source_account.sync_later
|
||||
destination_account.sync_later
|
||||
end
|
||||
|
||||
transfer
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :family, :source_account, :destination_account, :date, :amount
|
||||
|
||||
def outflow_transaction
|
||||
name = "#{name_prefix} to #{destination_account.name}"
|
||||
|
||||
Transaction.new(
|
||||
kind: outflow_transaction_kind,
|
||||
entry: source_account.entries.build(
|
||||
amount: amount.abs,
|
||||
currency: source_account.currency,
|
||||
date: date,
|
||||
name: name,
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def inflow_transaction
|
||||
name = "#{name_prefix} from #{source_account.name}"
|
||||
|
||||
Transaction.new(
|
||||
kind: "funds_movement",
|
||||
entry: destination_account.entries.build(
|
||||
amount: inflow_converted_money.amount.abs * -1,
|
||||
currency: destination_account.currency,
|
||||
date: date,
|
||||
name: name,
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# If destination account has different currency, its transaction should show up as converted
|
||||
# Future improvement: instead of a 1:1 conversion fallback, add a UI/UX flow for missing rates
|
||||
def inflow_converted_money
|
||||
Money.new(amount.abs, source_account.currency)
|
||||
.exchange_to(
|
||||
destination_account.currency,
|
||||
date: date,
|
||||
fallback_rate: 1.0
|
||||
)
|
||||
end
|
||||
|
||||
# The "expense" side of a transfer is treated different in analytics based on where it goes.
|
||||
def outflow_transaction_kind
|
||||
if destination_account.loan?
|
||||
"loan_payment"
|
||||
elsif destination_account.liability?
|
||||
"cc_payment"
|
||||
else
|
||||
"funds_movement"
|
||||
end
|
||||
end
|
||||
|
||||
def name_prefix
|
||||
if destination_account.liability?
|
||||
"Payment"
|
||||
else
|
||||
"Transfer"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -5,6 +5,8 @@ class User < ApplicationRecord
|
|||
belongs_to :last_viewed_chat, class_name: "Chat", optional: true
|
||||
has_many :sessions, dependent: :destroy
|
||||
has_many :chats, dependent: :destroy
|
||||
has_many :api_keys, dependent: :destroy
|
||||
has_many :mobile_devices, dependent: :destroy
|
||||
has_many :invitations, foreign_key: :inviter_id, dependent: :destroy
|
||||
has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy
|
||||
has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy
|
||||
|
|
85
app/services/api_rate_limiter.rb
Normal file
85
app/services/api_rate_limiter.rb
Normal file
|
@ -0,0 +1,85 @@
|
|||
class ApiRateLimiter
|
||||
# Rate limit tiers (requests per hour)
|
||||
RATE_LIMITS = {
|
||||
standard: 100,
|
||||
premium: 1000,
|
||||
enterprise: 10000
|
||||
}.freeze
|
||||
|
||||
DEFAULT_TIER = :standard
|
||||
|
||||
def initialize(api_key)
|
||||
@api_key = api_key
|
||||
@redis = Redis.new
|
||||
end
|
||||
|
||||
# Check if the API key has exceeded its rate limit
|
||||
def rate_limit_exceeded?
|
||||
current_count >= rate_limit
|
||||
end
|
||||
|
||||
# Increment the request count for this API key
|
||||
def increment_request_count!
|
||||
key = redis_key
|
||||
current_time = Time.current.to_i
|
||||
window_start = (current_time / 3600) * 3600 # Hourly window
|
||||
|
||||
@redis.multi do |transaction|
|
||||
# Use a sliding window with hourly buckets
|
||||
transaction.hincrby(key, window_start.to_s, 1)
|
||||
transaction.expire(key, 7200) # Keep data for 2 hours to handle sliding window
|
||||
end
|
||||
end
|
||||
|
||||
# Get current request count within the current hour
|
||||
def current_count
|
||||
key = redis_key
|
||||
current_time = Time.current.to_i
|
||||
window_start = (current_time / 3600) * 3600
|
||||
|
||||
count = @redis.hget(key, window_start.to_s)
|
||||
count.to_i
|
||||
end
|
||||
|
||||
# Get the rate limit for this API key's tier
|
||||
def rate_limit
|
||||
tier = determine_tier
|
||||
RATE_LIMITS[tier]
|
||||
end
|
||||
|
||||
# Calculate seconds until the rate limit resets
|
||||
def reset_time
|
||||
current_time = Time.current.to_i
|
||||
next_window = ((current_time / 3600) + 1) * 3600
|
||||
next_window - current_time
|
||||
end
|
||||
|
||||
# Get detailed usage information
|
||||
def usage_info
|
||||
{
|
||||
current_count: current_count,
|
||||
rate_limit: rate_limit,
|
||||
remaining: [ rate_limit - current_count, 0 ].max,
|
||||
reset_time: reset_time,
|
||||
tier: determine_tier
|
||||
}
|
||||
end
|
||||
|
||||
# Class method to get usage for an API key without incrementing
|
||||
def self.usage_for(api_key)
|
||||
new(api_key).usage_info
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def redis_key
|
||||
"api_rate_limit:#{@api_key.id}"
|
||||
end
|
||||
|
||||
def determine_tier
|
||||
# For now, all API keys are standard tier
|
||||
# This can be extended later to support different tiers based on user subscription
|
||||
# or API key configuration
|
||||
DEFAULT_TIER
|
||||
end
|
||||
end
|
17
app/views/api/v1/accounts/index.json.jbuilder
Normal file
17
app/views/api/v1/accounts/index.json.jbuilder
Normal file
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
json.accounts @accounts do |account|
|
||||
json.id account.id
|
||||
json.name account.name
|
||||
json.balance account.balance_money.format
|
||||
json.currency account.currency
|
||||
json.classification account.classification
|
||||
json.account_type account.accountable_type.underscore
|
||||
end
|
||||
|
||||
json.pagination do
|
||||
json.page @pagy.page
|
||||
json.per_page @per_page
|
||||
json.total_count @pagy.count
|
||||
json.total_pages @pagy.pages
|
||||
end
|
7
app/views/api/v1/chats/_chat.json.jbuilder
Normal file
7
app/views/api/v1/chats/_chat.json.jbuilder
Normal file
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
json.id chat.id
|
||||
json.title chat.title
|
||||
json.error chat.error.present? ? chat.error : nil
|
||||
json.created_at chat.created_at.iso8601
|
||||
json.updated_at chat.updated_at.iso8601
|
18
app/views/api/v1/chats/index.json.jbuilder
Normal file
18
app/views/api/v1/chats/index.json.jbuilder
Normal file
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
json.chats @chats do |chat|
|
||||
json.id chat.id
|
||||
json.title chat.title
|
||||
json.last_message_at chat.messages.ordered.first&.created_at&.iso8601
|
||||
json.message_count chat.messages.count
|
||||
json.error chat.error.present? ? chat.error : nil
|
||||
json.created_at chat.created_at.iso8601
|
||||
json.updated_at chat.updated_at.iso8601
|
||||
end
|
||||
|
||||
json.pagination do
|
||||
json.page @pagy.page
|
||||
json.per_page @pagy.vars[:items]
|
||||
json.total_count @pagy.count
|
||||
json.total_pages @pagy.pages
|
||||
end
|
33
app/views/api/v1/chats/show.json.jbuilder
Normal file
33
app/views/api/v1/chats/show.json.jbuilder
Normal file
|
@ -0,0 +1,33 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
json.partial! "chat", chat: @chat
|
||||
|
||||
json.messages @messages do |message|
|
||||
json.id message.id
|
||||
json.type message.type.underscore
|
||||
json.role message.role
|
||||
json.content message.content
|
||||
json.model message.ai_model if message.type == "AssistantMessage"
|
||||
json.created_at message.created_at.iso8601
|
||||
json.updated_at message.updated_at.iso8601
|
||||
|
||||
# Include tool calls for assistant messages
|
||||
if message.type == "AssistantMessage" && message.tool_calls.any?
|
||||
json.tool_calls message.tool_calls do |tool_call|
|
||||
json.id tool_call.id
|
||||
json.function_name tool_call.function_name
|
||||
json.function_arguments tool_call.function_arguments
|
||||
json.function_result tool_call.function_result
|
||||
json.created_at tool_call.created_at.iso8601
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if @pagy
|
||||
json.pagination do
|
||||
json.page @pagy.page
|
||||
json.per_page @pagy.vars[:items]
|
||||
json.total_count @pagy.count
|
||||
json.total_pages @pagy.pages
|
||||
end
|
||||
end
|
16
app/views/api/v1/messages/show.json.jbuilder
Normal file
16
app/views/api/v1/messages/show.json.jbuilder
Normal file
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
json.id @message.id
|
||||
json.chat_id @message.chat_id
|
||||
json.type @message.type.underscore
|
||||
json.role @message.role
|
||||
json.content @message.content
|
||||
json.model @message.ai_model if @message.type == "AssistantMessage"
|
||||
json.created_at @message.created_at.iso8601
|
||||
json.updated_at @message.updated_at.iso8601
|
||||
|
||||
# Note: AI response will be processed asynchronously
|
||||
if @message.type == "UserMessage"
|
||||
json.ai_response_status "pending"
|
||||
json.ai_response_message "AI response is being generated"
|
||||
end
|
76
app/views/api/v1/transactions/_transaction.json.jbuilder
Normal file
76
app/views/api/v1/transactions/_transaction.json.jbuilder
Normal file
|
@ -0,0 +1,76 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
json.id transaction.id
|
||||
json.date transaction.entry.date
|
||||
json.amount transaction.entry.amount_money.format
|
||||
json.currency transaction.entry.currency
|
||||
json.name transaction.entry.name
|
||||
json.notes transaction.entry.notes
|
||||
json.classification transaction.entry.classification
|
||||
|
||||
# Account information
|
||||
json.account do
|
||||
json.id transaction.entry.account.id
|
||||
json.name transaction.entry.account.name
|
||||
json.account_type transaction.entry.account.accountable_type.underscore
|
||||
end
|
||||
|
||||
# Category information
|
||||
if transaction.category.present?
|
||||
json.category do
|
||||
json.id transaction.category.id
|
||||
json.name transaction.category.name
|
||||
json.classification transaction.category.classification
|
||||
json.color transaction.category.color
|
||||
json.icon transaction.category.lucide_icon
|
||||
end
|
||||
else
|
||||
json.category nil
|
||||
end
|
||||
|
||||
# Merchant information
|
||||
if transaction.merchant.present?
|
||||
json.merchant do
|
||||
json.id transaction.merchant.id
|
||||
json.name transaction.merchant.name
|
||||
end
|
||||
else
|
||||
json.merchant nil
|
||||
end
|
||||
|
||||
# Tags
|
||||
json.tags transaction.tags do |tag|
|
||||
json.id tag.id
|
||||
json.name tag.name
|
||||
json.color tag.color
|
||||
end
|
||||
|
||||
# Transfer information (if this transaction is part of a transfer)
|
||||
if transaction.transfer.present?
|
||||
json.transfer do
|
||||
json.id transaction.transfer.id
|
||||
json.amount transaction.transfer.amount_abs.format
|
||||
json.currency transaction.transfer.inflow_transaction.entry.currency
|
||||
|
||||
# Other transaction in the transfer
|
||||
if transaction.transfer.inflow_transaction == transaction
|
||||
other_transaction = transaction.transfer.outflow_transaction
|
||||
else
|
||||
other_transaction = transaction.transfer.inflow_transaction
|
||||
end
|
||||
|
||||
if other_transaction.present?
|
||||
json.other_account do
|
||||
json.id other_transaction.entry.account.id
|
||||
json.name other_transaction.entry.account.name
|
||||
json.account_type other_transaction.entry.account.accountable_type.underscore
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
json.transfer nil
|
||||
end
|
||||
|
||||
# Additional metadata
|
||||
json.created_at transaction.created_at.iso8601
|
||||
json.updated_at transaction.updated_at.iso8601
|
12
app/views/api/v1/transactions/index.json.jbuilder
Normal file
12
app/views/api/v1/transactions/index.json.jbuilder
Normal file
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
json.transactions @transactions do |transaction|
|
||||
json.partial! "transaction", transaction: transaction
|
||||
end
|
||||
|
||||
json.pagination do
|
||||
json.page @pagy.page
|
||||
json.per_page @per_page
|
||||
json.total_count @pagy.count
|
||||
json.total_pages @pagy.pages
|
||||
end
|
3
app/views/api/v1/transactions/show.json.jbuilder
Normal file
3
app/views/api/v1/transactions/show.json.jbuilder
Normal file
|
@ -0,0 +1,3 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
json.partial! "transaction", transaction: @transaction
|
|
@ -9,81 +9,81 @@
|
|||
class="bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
|
||||
data-list-filter-target="input"
|
||||
data-action="list-filter#filter">
|
||||
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
|
||||
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-list-filter-target="list" class="flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar">
|
||||
<div class="pb-2 pl-4 mr-2 text-secondary hidden" data-list-filter-target="emptyMessage">
|
||||
<%= t(".no_categories") %>
|
||||
</div>
|
||||
<% if @categories.any? %>
|
||||
<% Category::Group.for(@categories).each do |group| %>
|
||||
<%= render "category/dropdowns/row", category: group.category %>
|
||||
<div data-list-filter-target="list" class="flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar">
|
||||
<div class="pb-2 pl-4 mr-2 text-secondary hidden" data-list-filter-target="emptyMessage">
|
||||
<%= t(".no_categories") %>
|
||||
</div>
|
||||
<% if @categories.any? %>
|
||||
<% Category::Group.for(@categories).each do |group| %>
|
||||
<%= render "category/dropdowns/row", category: group.category %>
|
||||
|
||||
<% group.subcategories.each do |category| %>
|
||||
<%= render "category/dropdowns/row", category: category %>
|
||||
<% group.subcategories.each do |category| %>
|
||||
<%= render "category/dropdowns/row", category: category %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="text-center flex flex-col items-center max-w-[500px]">
|
||||
<p class="text-sm text-secondary font-normal mb-4"><%= t(".empty") %></p>
|
||||
<% else %>
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="text-center flex flex-col items-center max-w-[500px]">
|
||||
<p class="text-sm text-secondary font-normal mb-4"><%= t(".empty") %></p>
|
||||
|
||||
<%= render ButtonComponent.new(
|
||||
<%= render ButtonComponent.new(
|
||||
text: t(".bootstrap"),
|
||||
variant: "outline",
|
||||
href: bootstrap_categories_path,
|
||||
method: :post,
|
||||
data: { turbo_frame: :_top }) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render "shared/ruler", classes: "my-2" %>
|
||||
<%= render "shared/ruler", classes: "my-2" %>
|
||||
|
||||
<div class="relative p-1.5 w-full">
|
||||
<% if @transaction.category %>
|
||||
<%= button_to transaction_path(@transaction.entry),
|
||||
<div class="relative p-1.5 w-full">
|
||||
<% if @transaction.category %>
|
||||
<%= button_to transaction_path(@transaction.entry),
|
||||
method: :patch,
|
||||
data: { turbo_frame: dom_id(@transaction.entry) },
|
||||
params: { entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
|
||||
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover" do %>
|
||||
<%= icon("minus") %>
|
||||
<%= icon("minus") %>
|
||||
|
||||
<%= t(".clear") %>
|
||||
<%= t(".clear") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% unless @transaction.transfer? %>
|
||||
<%= link_to new_transaction_transfer_match_path(@transaction.entry),
|
||||
<% unless @transaction.transfer? %>
|
||||
<%= link_to new_transaction_transfer_match_path(@transaction.entry),
|
||||
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= icon("refresh-cw") %>
|
||||
<%= icon("refresh-cw") %>
|
||||
|
||||
<p>Match transfer/payment</p>
|
||||
<p>Match transfer/payment</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form_with url: transaction_path(@transaction.entry),
|
||||
<div class="flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form_with url: transaction_path(@transaction.entry),
|
||||
method: :patch,
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.hidden_field "entry[excluded]", value: !@transaction.entry.excluded %>
|
||||
<%= f.check_box "entry[excluded]",
|
||||
<%= f.hidden_field "entry[excluded]", value: !@transaction.entry.excluded %>
|
||||
<%= f.check_box "entry[excluded]",
|
||||
checked: @transaction.entry.excluded,
|
||||
class: "checkbox checkbox--light",
|
||||
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<p>One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %></p>
|
||||
|
||||
<span class="text-orange-500 ml-auto">
|
||||
<%= icon("asterisk", color: "current") %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p>One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %></p>
|
||||
|
||||
<span class="text-orange-500 ml-auto">
|
||||
<%= icon("asterisk", color: "current") %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
6
app/views/doorkeeper/applications/_delete_form.html.erb
Normal file
6
app/views/doorkeeper/applications/_delete_form.html.erb
Normal file
|
@ -0,0 +1,6 @@
|
|||
<%- submit_btn_css ||= "btn btn-link" %>
|
||||
<%= form_tag oauth_application_path(application), method: :delete do %>
|
||||
<%= submit_tag t("doorkeeper.applications.buttons.destroy"),
|
||||
onclick: "return confirm('#{ t('doorkeeper.applications.confirmations.destroy') }')",
|
||||
class: submit_btn_css %>
|
||||
<% end %>
|
59
app/views/doorkeeper/applications/_form.html.erb
Normal file
59
app/views/doorkeeper/applications/_form.html.erb
Normal file
|
@ -0,0 +1,59 @@
|
|||
<%= form_for application, url: doorkeeper_submit_path(application), as: :doorkeeper_application, html: { role: "form" } do |f| %>
|
||||
<% if application.errors.any? %>
|
||||
<div class="alert alert-danger" data-alert><p><%= t("doorkeeper.applications.form.error") %></p></div>
|
||||
<% end %>
|
||||
|
||||
<div class="form-group row">
|
||||
<%= f.label :name, class: "col-sm-2 col-form-label font-weight-bold" %>
|
||||
<div class="col-sm-10">
|
||||
<%= f.text_field :name, class: "form-control #{ 'is-invalid' if application.errors[:name].present? }", required: true %>
|
||||
<%= doorkeeper_errors_for application, :name %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<%= f.label :redirect_uri, class: "col-sm-2 col-form-label font-weight-bold" %>
|
||||
<div class="col-sm-10">
|
||||
<%= f.text_area :redirect_uri, class: "form-control #{ 'is-invalid' if application.errors[:redirect_uri].present? }" %>
|
||||
<%= doorkeeper_errors_for application, :redirect_uri %>
|
||||
<span class="form-text text-secondary">
|
||||
<%= t("doorkeeper.applications.help.redirect_uri") %>
|
||||
</span>
|
||||
|
||||
<% if Doorkeeper.configuration.allow_blank_redirect_uri?(application) %>
|
||||
<span class="form-text text-secondary">
|
||||
<%= t("doorkeeper.applications.help.blank_redirect_uri") %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<%= f.label :confidential, class: "col-sm-2 form-check-label font-weight-bold" %>
|
||||
<div class="col-sm-10">
|
||||
<%= f.check_box :confidential, class: "checkbox #{ 'is-invalid' if application.errors[:confidential].present? }" %>
|
||||
<%= doorkeeper_errors_for application, :confidential %>
|
||||
<span class="form-text text-secondary">
|
||||
<%= t("doorkeeper.applications.help.confidential") %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<%= f.label :scopes, class: "col-sm-2 col-form-label font-weight-bold" %>
|
||||
<div class="col-sm-10">
|
||||
<%= f.text_field :scopes, class: "form-control #{ 'has-error' if application.errors[:scopes].present? }" %>
|
||||
<%= doorkeeper_errors_for application, :scopes %>
|
||||
<span class="form-text text-secondary">
|
||||
<%= t("doorkeeper.applications.help.scopes") %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<%= f.submit t("doorkeeper.applications.buttons.submit"), class: "btn btn-primary" %>
|
||||
<%= link_to t("doorkeeper.applications.buttons.cancel"), oauth_applications_path, class: "btn btn-secondary" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
5
app/views/doorkeeper/applications/edit.html.erb
Normal file
5
app/views/doorkeeper/applications/edit.html.erb
Normal file
|
@ -0,0 +1,5 @@
|
|||
<div class="border-bottom mb-4">
|
||||
<h1><%= t(".title") %></h1>
|
||||
</div>
|
||||
|
||||
<%= render "form", application: @application %>
|
38
app/views/doorkeeper/applications/index.html.erb
Normal file
38
app/views/doorkeeper/applications/index.html.erb
Normal file
|
@ -0,0 +1,38 @@
|
|||
<div class="border-bottom mb-4">
|
||||
<h1><%= t(".title") %></h1>
|
||||
</div>
|
||||
|
||||
<p><%= link_to t(".new"), new_oauth_application_path, class: "btn btn-success" %></p>
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t(".name") %></th>
|
||||
<th><%= t(".callback_url") %></th>
|
||||
<th><%= t(".confidential") %></th>
|
||||
<th><%= t(".actions") %></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @applications.each do |application| %>
|
||||
<tr id="application_<%= application.id %>">
|
||||
<td class="align-middle">
|
||||
<%= link_to application.name, oauth_application_path(application) %>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<%= simple_format(application.redirect_uri) %>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<%= application.confidential? ? t("doorkeeper.applications.index.confidentiality.yes") : t("doorkeeper.applications.index.confidentiality.no") %>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<%= link_to t("doorkeeper.applications.buttons.edit"), edit_oauth_application_path(application), class: "btn btn-link" %>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<%= render "delete_form", application: application %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
5
app/views/doorkeeper/applications/new.html.erb
Normal file
5
app/views/doorkeeper/applications/new.html.erb
Normal file
|
@ -0,0 +1,5 @@
|
|||
<div class="border-bottom mb-4">
|
||||
<h1><%= t(".title") %></h1>
|
||||
</div>
|
||||
|
||||
<%= render "form", application: @application %>
|
63
app/views/doorkeeper/applications/show.html.erb
Normal file
63
app/views/doorkeeper/applications/show.html.erb
Normal file
|
@ -0,0 +1,63 @@
|
|||
<div class="border-bottom mb-4">
|
||||
<h1><%= t(".title", name: @application.name) %></h1>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h4><%= t(".application_id") %>:</h4>
|
||||
<p><code class="bg-light" id="application_id"><%= @application.uid %></code></p>
|
||||
|
||||
<h4><%= t(".secret") %>:</h4>
|
||||
<p>
|
||||
<code class="bg-light" id="secret">
|
||||
<% secret = flash[:application_secret].presence || @application.plaintext_secret %>
|
||||
<% if secret.blank? && Doorkeeper.config.application_secret_hashed? %>
|
||||
<span class="bg-light font-italic text-uppercase text-muted"><%= t(".secret_hashed") %></span>
|
||||
<% else %>
|
||||
<%= secret %>
|
||||
<% end %>
|
||||
</code>
|
||||
</p>
|
||||
|
||||
<h4><%= t(".scopes") %>:</h4>
|
||||
<p>
|
||||
<code class="bg-light" id="scopes">
|
||||
<% if @application.scopes.present? %>
|
||||
<%= @application.scopes %>
|
||||
<% else %>
|
||||
<span class="bg-light font-italic text-uppercase text-muted"><%= t(".not_defined") %></span>
|
||||
<% end %>
|
||||
</code>
|
||||
</p>
|
||||
|
||||
<h4><%= t(".confidential") %>:</h4>
|
||||
<p><code class="bg-light" id="confidential"><%= @application.confidential? %></code></p>
|
||||
|
||||
<h4><%= t(".callback_urls") %>:</h4>
|
||||
|
||||
<% if @application.redirect_uri.present? %>
|
||||
<table>
|
||||
<% @application.redirect_uri.split.each do |uri| %>
|
||||
<tr>
|
||||
<td>
|
||||
<code class="bg-light"><%= uri %></code>
|
||||
</td>
|
||||
<td>
|
||||
<%= link_to t("doorkeeper.applications.buttons.authorize"), oauth_authorization_path(client_id: @application.uid, redirect_uri: uri, response_type: "code", scope: @application.scopes), class: "btn btn-success", target: "_blank" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</table>
|
||||
<% else %>
|
||||
<span class="bg-light font-italic text-uppercase text-muted"><%= t(".not_defined") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<h3><%= t(".actions") %></h3>
|
||||
|
||||
<p><%= link_to t("doorkeeper.applications.buttons.edit"), edit_oauth_application_path(@application), class: "btn btn-primary" %></p>
|
||||
|
||||
<p><%= render "delete_form", application: @application, submit_btn_css: "btn btn-danger" %></p>
|
||||
</div>
|
||||
</div>
|
22
app/views/doorkeeper/authorizations/error.html.erb
Normal file
22
app/views/doorkeeper/authorizations/error.html.erb
Normal file
|
@ -0,0 +1,22 @@
|
|||
<div class="bg-container rounded-xl p-6 space-y-6">
|
||||
<div class="text-center space-y-2">
|
||||
<div class="mx-auto w-12 h-12 rounded-full bg-destructive-surface flex items-center justify-center mb-4">
|
||||
<%= icon("alert-circle", class: "w-6 h-6 text-destructive") %>
|
||||
</div>
|
||||
<h1 class="text-2xl font-medium text-primary"><%= t("doorkeeper.authorizations.error.title") %></h1>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-lg p-4">
|
||||
<p class="text-sm text-secondary">
|
||||
<%= (local_assigns[:error_response] ? error_response : @pre_auth.error_response).body[:error_description] %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<%= render LinkComponent.new(
|
||||
text: "Go back",
|
||||
href: "javascript:history.back()",
|
||||
variant: :secondary
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
22
app/views/doorkeeper/authorizations/form_post.html.erb
Normal file
22
app/views/doorkeeper/authorizations/form_post.html.erb
Normal file
|
@ -0,0 +1,22 @@
|
|||
<div class="bg-container rounded-xl p-6 space-y-6">
|
||||
<div class="text-center space-y-2">
|
||||
<div class="mx-auto w-12 h-12 rounded-full bg-surface-inset flex items-center justify-center mb-4">
|
||||
<%= icon("loader-circle", class: "w-6 h-6 text-primary animate-spin") %>
|
||||
</div>
|
||||
<h1 class="text-2xl font-medium text-primary"><%= t(".title") %></h1>
|
||||
<p class="text-sm text-secondary">Redirecting you back to the application...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% turbo_disabled = @pre_auth.redirect_uri&.start_with?("maybeapp://") || params[:display] == "mobile" %>
|
||||
<%= form_tag @pre_auth.redirect_uri, method: :post, name: :redirect_form, authenticity_token: false, data: { turbo: !turbo_disabled } do %>
|
||||
<% auth.body.compact.each do |key, value| %>
|
||||
<%= hidden_field_tag key, value %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
window.onload = function () {
|
||||
document.forms['redirect_form'].submit();
|
||||
};
|
||||
</script>
|
76
app/views/doorkeeper/authorizations/new.html.erb
Normal file
76
app/views/doorkeeper/authorizations/new.html.erb
Normal file
|
@ -0,0 +1,76 @@
|
|||
<% if params[:redirect_uri]&.start_with?('maybeapp://') || params[:display] == 'mobile' %>
|
||||
<meta name="turbo-visit-control" content="reload">
|
||||
<% end %>
|
||||
|
||||
<div class="bg-container rounded-xl p-6 space-y-6">
|
||||
<div class="space-y-2 text-center">
|
||||
<p class="text-sm text-secondary">
|
||||
<%= raw t(".prompt", client_name: content_tag(:span, @pre_auth.client.name, class: "font-medium text-primary")) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<% if @pre_auth.scopes.count > 0 %>
|
||||
<div class="bg-surface-inset rounded-lg p-4 space-y-3">
|
||||
<p class="text-sm font-medium text-primary"><%= t(".able_to") %>:</p>
|
||||
<ul class="space-y-2">
|
||||
<% @pre_auth.scopes.each do |scope| %>
|
||||
<li class="flex items-start gap-2 text-sm text-secondary">
|
||||
<%= icon("check", class: "w-4 h-4 mt-0.5 text-success") %>
|
||||
<span><%= t scope, scope: [:doorkeeper, :scopes] %></span>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-3">
|
||||
<% turbo_disabled = params[:redirect_uri]&.start_with?("maybeapp://") || params[:display] == "mobile" %>
|
||||
<%= form_tag oauth_authorization_path, method: :post, class: "w-full", data: { turbo: !turbo_disabled } do %>
|
||||
<%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>
|
||||
<%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %>
|
||||
<%= hidden_field_tag :state, @pre_auth.state, id: nil %>
|
||||
<%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %>
|
||||
<%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %>
|
||||
<%= hidden_field_tag :scope, @pre_auth.scope, id: nil %>
|
||||
<%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %>
|
||||
<%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %>
|
||||
<% if params[:display].present? %>
|
||||
<%= hidden_field_tag :display, params[:display], id: nil %>
|
||||
<% end %>
|
||||
<%= render ButtonComponent.new(
|
||||
text: t("doorkeeper.authorizations.buttons.authorize"),
|
||||
variant: :primary,
|
||||
size: :lg,
|
||||
full_width: true,
|
||||
href: oauth_authorization_path,
|
||||
data: { disable_with: "Authorizing..." }
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
<%= form_tag oauth_authorization_path, method: :delete, class: "w-full", data: { turbo: !turbo_disabled } do %>
|
||||
<%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>
|
||||
<%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %>
|
||||
<%= hidden_field_tag :state, @pre_auth.state, id: nil %>
|
||||
<%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %>
|
||||
<%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %>
|
||||
<%= hidden_field_tag :scope, @pre_auth.scope, id: nil %>
|
||||
<%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %>
|
||||
<%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %>
|
||||
<% if params[:display].present? %>
|
||||
<%= hidden_field_tag :display, params[:display], id: nil %>
|
||||
<% end %>
|
||||
<%= render ButtonComponent.new(
|
||||
text: t("doorkeeper.authorizations.buttons.deny"),
|
||||
variant: :outline,
|
||||
size: :lg,
|
||||
full_width: true,
|
||||
href: oauth_authorization_path,
|
||||
data: { disable_with: "Denying..." }
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-tertiary text-center">
|
||||
By authorizing, you allow this app to access your Maybe data according to the permissions above.
|
||||
</p>
|
||||
</div>
|
17
app/views/doorkeeper/authorizations/show.html.erb
Normal file
17
app/views/doorkeeper/authorizations/show.html.erb
Normal file
|
@ -0,0 +1,17 @@
|
|||
<div class="bg-container rounded-xl p-6 space-y-6">
|
||||
<div class="text-center space-y-2">
|
||||
<div class="mx-auto w-12 h-12 rounded-full bg-success-surface flex items-center justify-center mb-4">
|
||||
<%= icon("check", class: "w-6 h-6 text-success") %>
|
||||
</div>
|
||||
<h1 class="text-2xl font-medium text-primary"><%= t(".title") %></h1>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-lg p-4">
|
||||
<p class="text-xs text-secondary mb-2">Authorization Code:</p>
|
||||
<code id="authorization_code" class="block text-sm font-mono text-primary break-all"><%= params[:code] %></code>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-secondary text-center">
|
||||
Copy this code and paste it into the application.
|
||||
</p>
|
||||
</div>
|
|
@ -0,0 +1,4 @@
|
|||
<%- submit_btn_css ||= "btn btn-link" %>
|
||||
<%= form_tag oauth_authorized_application_path(application), method: :delete do %>
|
||||
<%= submit_tag t("doorkeeper.authorized_applications.buttons.revoke"), onclick: "return confirm('#{ t('doorkeeper.authorized_applications.confirmations.revoke') }')", class: submit_btn_css %>
|
||||
<% end %>
|
24
app/views/doorkeeper/authorized_applications/index.html.erb
Normal file
24
app/views/doorkeeper/authorized_applications/index.html.erb
Normal file
|
@ -0,0 +1,24 @@
|
|||
<header class="page-header">
|
||||
<h1><%= t("doorkeeper.authorized_applications.index.title") %></h1>
|
||||
</header>
|
||||
|
||||
<main role="main">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("doorkeeper.authorized_applications.index.application") %></th>
|
||||
<th><%= t("doorkeeper.authorized_applications.index.created_at") %></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @applications.each do |application| %>
|
||||
<tr>
|
||||
<td><%= application.name %></td>
|
||||
<td><%= application.created_at.strftime(t("doorkeeper.authorized_applications.index.date_format")) %></td>
|
||||
<td><%= render "delete_form", application: application %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
39
app/views/layouts/doorkeeper/admin.html.erb
Normal file
39
app/views/layouts/doorkeeper/admin.html.erb
Normal file
|
@ -0,0 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= t("doorkeeper.layouts.admin.title") %></title>
|
||||
<%= stylesheet_link_tag "doorkeeper/admin/application" %>
|
||||
<%= csrf_meta_tags %>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-5">
|
||||
<%= link_to t("doorkeeper.layouts.admin.nav.oauth2_provider"), oauth_applications_path, class: "navbar-brand" %>
|
||||
|
||||
<div class="collapse navbar-collapse">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item <%= "active" if request.path == oauth_applications_path %>">
|
||||
<%= link_to t("doorkeeper.layouts.admin.nav.applications"), oauth_applications_path, class: "nav-link" %>
|
||||
</li>
|
||||
<% if respond_to?(:root_path) %>
|
||||
<li class="nav-item">
|
||||
<%= link_to t("doorkeeper.layouts.admin.nav.home"), root_path, class: "nav-link" %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="doorkeeper-admin container">
|
||||
<%- if flash[:notice].present? %>
|
||||
<div class="alert alert-info">
|
||||
<%= flash[:notice] %>
|
||||
</div>
|
||||
<% end -%>
|
||||
|
||||
<%= yield %>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
48
app/views/layouts/doorkeeper/application.html.erb
Normal file
48
app/views/layouts/doorkeeper/application.html.erb
Normal file
|
@ -0,0 +1,48 @@
|
|||
<!DOCTYPE html>
|
||||
<% theme = Current.user&.theme || "system" %>
|
||||
<html
|
||||
lang="en"
|
||||
data-theme="<%= theme %>"
|
||||
data-controller="theme"
|
||||
data-theme-user-preference-value="<%= Current.user&.theme || "system" %>"
|
||||
class="h-full text-primary overflow-hidden lg:overflow-auto font-sans">
|
||||
<head>
|
||||
<%= render "layouts/shared/head" %>
|
||||
</head>
|
||||
|
||||
<body class="h-full overflow-hidden lg:overflow-auto antialiased">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex flex-col h-full px-6 py-12 bg-surface">
|
||||
<div class="grow flex flex-col justify-center">
|
||||
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div class="flex justify-center mt-2 md:mb-6">
|
||||
<%= image_tag "logo-color.png", class: "w-16 mb-6" %>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-3xl font-medium text-primary text-center">
|
||||
Maybe Authorization
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 md:mt-8 sm:mx-auto sm:w-full sm:max-w-lg">
|
||||
<%- if flash[:notice].present? %>
|
||||
<div class="mb-4 p-3 rounded-lg bg-surface-inset text-sm text-secondary">
|
||||
<%= flash[:notice] %>
|
||||
</div>
|
||||
<% end -%>
|
||||
<%- if flash[:alert].present? %>
|
||||
<div class="mb-4 p-3 rounded-lg bg-destructive-surface text-sm text-destructive">
|
||||
<%= flash[:alert] %>
|
||||
</div>
|
||||
<% end -%>
|
||||
|
||||
<%= yield %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render "layouts/shared/footer" %>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -6,6 +6,7 @@ nav_sections = [
|
|||
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
|
||||
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
|
||||
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
|
||||
{ label: "API Key", path: settings_api_key_path, icon: "key" },
|
||||
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
|
||||
{ label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? },
|
||||
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },
|
||||
|
|
94
app/views/settings/api_keys/created.html.erb
Normal file
94
app/views/settings/api_keys/created.html.erb
Normal file
|
@ -0,0 +1,94 @@
|
|||
<%= content_for :page_title, "API Key Created" %>
|
||||
|
||||
<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %>
|
||||
<div class="space-y-4">
|
||||
<div class="p-3 shadow-border-xs bg-container rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= render FilledIconComponent.new(
|
||||
icon: "check-circle",
|
||||
rounded: true,
|
||||
size: "lg",
|
||||
variant: :success
|
||||
) %>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium text-primary">API Key Created Successfully!</h3>
|
||||
<p class="text-secondary text-sm mt-1">Your new API key "<%= @api_key.name %>" has been created and is ready to use.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-xl p-4">
|
||||
<h4 class="font-medium text-primary mb-3">Your API Key</h4>
|
||||
<p class="text-secondary text-sm mb-3">Copy and store this key securely. You'll need it to authenticate your API requests.</p>
|
||||
|
||||
<div class="bg-container rounded-lg p-3 border border-primary" data-controller="clipboard">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<code id="api-key-display" class="font-mono text-sm text-primary break-all" data-clipboard-target="source"><%= @api_key.plain_key %></code>
|
||||
<%= render ButtonComponent.new(
|
||||
text: "Copy API Key",
|
||||
variant: "ghost",
|
||||
icon: "copy",
|
||||
data: { action: "clipboard#copy" }
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-xl p-4">
|
||||
<h4 class="font-medium text-primary mb-3">Key Details</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-secondary">Name:</span>
|
||||
<span class="text-primary font-medium"><%= @api_key.name %></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-secondary">Permissions:</span>
|
||||
<span class="text-primary">
|
||||
<%= @api_key.scopes.map { |scope|
|
||||
case scope
|
||||
when "read_accounts" then "View Accounts"
|
||||
when "read_transactions" then "View Transactions"
|
||||
when "read_balances" then "View Balances"
|
||||
when "write_transactions" then "Create Transactions"
|
||||
else scope.humanize
|
||||
end
|
||||
}.join(", ") %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-secondary">Created:</span>
|
||||
<span class="text-primary"><%= @api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-warning-50 border border-warning-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %>
|
||||
<div>
|
||||
<h4 class="font-medium text-warning-800 text-sm">Important Security Note</h4>
|
||||
<p class="text-warning-700 text-sm mt-1">
|
||||
This is the only time your API key will be displayed. Make sure to copy it now and store it securely.
|
||||
If you lose this key, you'll need to generate a new one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-xl p-4">
|
||||
<h4 class="font-medium text-primary mb-3">How to use your API key</h4>
|
||||
<p class="text-secondary text-sm mb-3">Include your API key in the X-Api-Key header when making requests:</p>
|
||||
<div class="bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary">
|
||||
curl -H "X-Api-Key: <%= @api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4 border-t border-primary">
|
||||
<%= render LinkComponent.new(
|
||||
text: "Continue to API Key Settings",
|
||||
href: settings_api_key_path,
|
||||
variant: "primary"
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
102
app/views/settings/api_keys/created.turbo_stream.erb
Normal file
102
app/views/settings/api_keys/created.turbo_stream.erb
Normal file
|
@ -0,0 +1,102 @@
|
|||
<%= turbo_stream.update "main" do %>
|
||||
<div class="relative max-w-4xl mx-auto flex flex-col w-full h-full">
|
||||
<div class="grow space-y-4 overflow-y-auto -mx-1 px-1 pb-12">
|
||||
<h1 class="text-primary text-3xl md:text-xl font-medium">
|
||||
API Key Created
|
||||
</h1>
|
||||
|
||||
<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %>
|
||||
<div class="space-y-4">
|
||||
<div class="p-3 shadow-border-xs bg-container rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= render FilledIconComponent.new(
|
||||
icon: "check-circle",
|
||||
rounded: true,
|
||||
size: "lg",
|
||||
variant: :success
|
||||
) %>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium text-primary">API Key Created Successfully!</h3>
|
||||
<p class="text-secondary text-sm mt-1">Your new API key "<%= @api_key.name %>" has been created and is ready to use.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-xl p-4">
|
||||
<h4 class="font-medium text-primary mb-3">Your API Key</h4>
|
||||
<p class="text-secondary text-sm mb-3">Copy and store this key securely. You'll need it to authenticate your API requests.</p>
|
||||
|
||||
<div class="bg-container rounded-lg p-3 border border-primary" data-controller="clipboard">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<code id="api-key-display" class="font-mono text-sm text-primary break-all" data-clipboard-target="source"><%= @api_key.plain_key %></code>
|
||||
<%= render ButtonComponent.new(
|
||||
text: "Copy API Key",
|
||||
variant: "ghost",
|
||||
icon: "copy",
|
||||
data: { action: "clipboard#copy" }
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-xl p-4">
|
||||
<h4 class="font-medium text-primary mb-3">Key Details</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-secondary">Name:</span>
|
||||
<span class="text-primary font-medium"><%= @api_key.name %></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-secondary">Permissions:</span>
|
||||
<span class="text-primary">
|
||||
<%= @api_key.scopes.map { |scope|
|
||||
case scope
|
||||
when "read_accounts" then "View Accounts"
|
||||
when "read_transactions" then "View Transactions"
|
||||
when "read_balances" then "View Balances"
|
||||
when "write_transactions" then "Create Transactions"
|
||||
else scope.humanize
|
||||
end
|
||||
}.join(", ") %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-secondary">Created:</span>
|
||||
<span class="text-primary"><%= @api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-warning-50 border border-warning-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %>
|
||||
<div>
|
||||
<h4 class="font-medium text-warning-800 text-sm">Important Security Note</h4>
|
||||
<p class="text-warning-700 text-sm mt-1">
|
||||
This is the only time your API key will be displayed. Make sure to copy it now and store it securely.
|
||||
If you lose this key, you'll need to generate a new one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-xl p-4">
|
||||
<h4 class="font-medium text-primary mb-3">How to use your API key</h4>
|
||||
<p class="text-secondary text-sm mb-3">Include your API key in the X-Api-Key header when making requests:</p>
|
||||
<div class="bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary">
|
||||
curl -H "X-Api-Key: <%= @api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4 border-t border-primary">
|
||||
<%= render LinkComponent.new(
|
||||
text: "Continue to API Key Settings",
|
||||
href: settings_api_key_path,
|
||||
variant: "primary"
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
60
app/views/settings/api_keys/new.html.erb
Normal file
60
app/views/settings/api_keys/new.html.erb
Normal file
|
@ -0,0 +1,60 @@
|
|||
<%= content_for :page_title, "Create New API Key" %>
|
||||
|
||||
<%= settings_section title: "Create New API Key", subtitle: "Generate a new API key to access your Maybe data programmatically." do %>
|
||||
<%= styled_form_with model: @api_key, url: settings_api_key_path, class: "space-y-4" do |form| %>
|
||||
<%= form.text_field :name,
|
||||
placeholder: "e.g., My Budget App, Portfolio Tracker",
|
||||
label: "API Key Name",
|
||||
help_text: "Choose a descriptive name to help you identify this key later." %>
|
||||
|
||||
<div>
|
||||
<%= form.label :scopes, "Permissions", class: "block text-sm font-medium text-primary mb-2" %>
|
||||
<p class="text-sm text-secondary mb-3">Select the permissions this API key should have:</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<% [
|
||||
["read", "Read Only", "View your accounts, transactions, and balances"],
|
||||
["read_write", "Read/Write", "View your data and create new transactions"]
|
||||
].each do |value, label, description| %>
|
||||
<div class="bg-surface-inset rounded-lg p-3 border border-primary">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<%= radio_button_tag "api_key[scopes]", value, (@api_key&.scopes || []).include?(value),
|
||||
class: "mt-1" %>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-primary"><%= label %></div>
|
||||
<div class="text-sm text-secondary mt-1"><%= description %></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-warning-50 border border-warning-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %>
|
||||
<div>
|
||||
<h4 class="font-medium text-warning-800 text-sm">Security Warning</h4>
|
||||
<p class="text-warning-700 text-sm mt-1">
|
||||
Your API key will be displayed only once after creation. Make sure to copy and store it securely.
|
||||
Anyone with access to this key can access your data according to the permissions you select.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-primary">
|
||||
<%= render LinkComponent.new(
|
||||
text: "Cancel",
|
||||
href: settings_api_key_path,
|
||||
variant: "ghost"
|
||||
) %>
|
||||
|
||||
<%= render ButtonComponent.new(
|
||||
text: "Create API Key",
|
||||
variant: "primary",
|
||||
type: "submit"
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
192
app/views/settings/api_keys/show.html.erb
Normal file
192
app/views/settings/api_keys/show.html.erb
Normal file
|
@ -0,0 +1,192 @@
|
|||
<%= content_for :page_title, "API Key" %>
|
||||
|
||||
<% if @newly_created && @plain_key %>
|
||||
<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %>
|
||||
<div class="space-y-4">
|
||||
<div class="p-3 shadow-border-xs bg-container rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= render FilledIconComponent.new(
|
||||
icon: "check-circle",
|
||||
rounded: true,
|
||||
size: "lg",
|
||||
variant: :success
|
||||
) %>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium text-primary">API Key Created Successfully!</h3>
|
||||
<p class="text-secondary text-sm mt-1">Your new API key "<%= @current_api_key.name %>" has been created and is ready to use.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-xl p-4">
|
||||
<h4 class="font-medium text-primary mb-3">Your API Key</h4>
|
||||
<p class="text-secondary text-sm mb-3">Copy and store this key securely. You'll need it to authenticate your API requests.</p>
|
||||
|
||||
<div class="bg-container rounded-lg p-3 border border-primary" data-controller="clipboard">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<code id="api-key-display" class="font-mono text-sm text-primary break-all" data-clipboard-target="source"><%= @current_api_key.plain_key %></code>
|
||||
<%= render ButtonComponent.new(
|
||||
text: "Copy API Key",
|
||||
variant: "ghost",
|
||||
icon: "copy",
|
||||
data: { action: "clipboard#copy" }
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-xl p-4">
|
||||
<h4 class="font-medium text-primary mb-3">How to use your API key</h4>
|
||||
<p class="text-secondary text-sm mb-3">Include your API key in the X-Api-Key header when making requests:</p>
|
||||
<div class="bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary">
|
||||
curl -H "X-Api-Key: <%= @current_api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4 border-t border-primary">
|
||||
<%= render LinkComponent.new(
|
||||
text: "Continue to API Key Settings",
|
||||
href: settings_api_key_path,
|
||||
variant: "primary"
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% elsif @current_api_key %>
|
||||
<%= settings_section title: "Your API Key", subtitle: "Manage your API key for programmatic access to your Maybe data." do %>
|
||||
<div class="space-y-4">
|
||||
<div class="p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<%= render FilledIconComponent.new(
|
||||
icon: "key",
|
||||
rounded: true,
|
||||
size: "lg"
|
||||
) %>
|
||||
|
||||
<div class="text-sm space-y-1">
|
||||
<p class="text-primary font-medium"><%= @current_api_key.name %></p>
|
||||
<p class="text-secondary">
|
||||
Created <%= time_ago_in_words(@current_api_key.created_at) %> ago
|
||||
<% if @current_api_key.last_used_at %>
|
||||
• Last used <%= time_ago_in_words(@current_api_key.last_used_at) %> ago
|
||||
<% else %>
|
||||
• Never used
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-success px-2 py-1">
|
||||
<p class="text-success-foreground font-medium text-xs">Active</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-xl p-4">
|
||||
<h4 class="font-medium text-primary mb-3">Permissions</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% @current_api_key.scopes.each do |scope| %>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-primary text-primary-foreground rounded-full text-xs font-medium">
|
||||
<%= icon("shield-check", class: "w-3 h-3") %>
|
||||
<%= case scope
|
||||
when "read" then "Read Only"
|
||||
when "read_write" then "Read/Write"
|
||||
else scope.humanize
|
||||
end %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-xl p-4">
|
||||
<h4 class="font-medium text-primary mb-3">Your API Key</h4>
|
||||
<p class="text-secondary text-sm mb-3">Copy and store this key securely. You'll need it to authenticate your API requests.</p>
|
||||
|
||||
<div class="bg-container rounded-lg p-3 border border-primary" data-controller="clipboard">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<code id="api-key-display" class="font-mono text-sm text-primary break-all" data-clipboard-target="source"><%= @current_api_key.plain_key %></code>
|
||||
<%= render ButtonComponent.new(
|
||||
text: "Copy API Key",
|
||||
variant: "ghost",
|
||||
icon: "copy",
|
||||
data: { action: "clipboard#copy" }
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-xl p-4">
|
||||
<h4 class="font-medium text-primary mb-3">How to use your API key</h4>
|
||||
<p class="text-secondary text-sm mb-3">Include your API key in the X-Api-Key header when making requests:</p>
|
||||
<div class="bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary">
|
||||
curl -H "X-Api-Key: <%= @current_api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-4 border-t border-primary">
|
||||
<%= render LinkComponent.new(
|
||||
text: "Create New Key",
|
||||
href: new_settings_api_key_path(regenerate: true),
|
||||
variant: "secondary"
|
||||
) %>
|
||||
|
||||
<%= render ButtonComponent.new(
|
||||
text: "Revoke Key",
|
||||
href: settings_api_key_path,
|
||||
method: :delete,
|
||||
variant: "destructive",
|
||||
data: {
|
||||
turbo_confirm: "Are you sure you want to revoke this API key?"
|
||||
}
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= settings_section title: "Create Your API Key", subtitle: "Get programmatic access to your Maybe data" do %>
|
||||
<div class="space-y-4">
|
||||
<div class="p-3 shadow-border-xs bg-container rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= render FilledIconComponent.new(
|
||||
icon: "key",
|
||||
rounded: true,
|
||||
size: "lg"
|
||||
) %>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium text-primary">Access your account data programmatically</h3>
|
||||
<p class="text-secondary text-sm mt-1">Generate an API key to integrate with your applications and access your financial data securely.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-inset rounded-xl p-4">
|
||||
<h4 class="font-medium text-primary mb-3">What you can do with API keys:</h4>
|
||||
<ul class="space-y-2 text-sm text-secondary">
|
||||
<li class="flex items-start gap-2">
|
||||
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
|
||||
<span>Access your accounts and balances</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
|
||||
<span>View transaction history</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
|
||||
<span>Create new transactions</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
|
||||
<span>Integrate with third-party applications</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-start">
|
||||
<%= render LinkComponent.new(
|
||||
text: "Create API Key",
|
||||
href: new_settings_api_key_path,
|
||||
variant: "primary"
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
|
@ -1,14 +1,11 @@
|
|||
<%# locals: (entry:) %>
|
||||
<%# locals: (model:, account:) %>
|
||||
|
||||
<% type = params[:type] || "buy" %>
|
||||
|
||||
<%= styled_form_with model: entry, url: trades_path, data: { controller: "trade-form" } do |form| %>
|
||||
|
||||
<%= form.hidden_field :account_id %>
|
||||
|
||||
<%= styled_form_with url: trades_path(account_id: account&.id), scope: :model, data: { controller: "trade-form" } do |form| %>
|
||||
<div class="space-y-4">
|
||||
<% if entry.errors.any? %>
|
||||
<%= render "shared/form_errors", model: entry %>
|
||||
<% if model.errors.any? %>
|
||||
<%= render "shared/form_errors", model: model %>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-2">
|
||||
|
@ -22,7 +19,7 @@
|
|||
{ label: t(".type"), selected: type },
|
||||
{ data: {
|
||||
action: "trade-form#changeType",
|
||||
trade_form_url_param: new_trade_path(account_id: entry.account&.id || entry.account_id),
|
||||
trade_form_url_param: new_trade_path(account_id: account&.id),
|
||||
trade_form_key_param: "type",
|
||||
}} %>
|
||||
|
||||
|
@ -41,10 +38,10 @@
|
|||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= form.date_field :date, label: true, value: Date.current, required: true %>
|
||||
<%= form.date_field :date, label: true, value: model.date || Date.current, required: true %>
|
||||
|
||||
<% unless %w[buy sell].include?(type) %>
|
||||
<%= form.money_field :amount, label: t(".amount"), required: true %>
|
||||
<%= form.money_field :amount, label: t(".amount"), value: model.amount, required: true %>
|
||||
<% end %>
|
||||
|
||||
<% if %w[deposit withdrawal].include?(type) %>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<%= render DialogComponent.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title")) %>
|
||||
<% dialog.with_body do %>
|
||||
<%= render "trades/form", entry: @entry %>
|
||||
<%= render "trades/form", model: @model, account: @account %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div class="grid grid-cols-1 md:grid-cols-3 bg-container rounded-xl shadow-border-xs md:divide-x divide-y md:divide-y-0 divide-alpha-black-100 theme-dark:divide-alpha-white-200">
|
||||
<div class="p-4 space-y-2">
|
||||
<p class="text-sm text-secondary">Total transactions</p>
|
||||
<p class="text-primary font-medium text-xl" id="total-transactions"><%= totals.transactions_count.round(0) %></p>
|
||||
<p class="text-primary font-medium text-xl" id="total-transactions"><%= totals.count.round(0) %></p>
|
||||
</div>
|
||||
<div class="p-4 space-y-2">
|
||||
<p class="text-sm text-secondary">Income</p>
|
||||
|
|
|
@ -6,11 +6,12 @@
|
|||
<%= turbo_frame_tag dom_id(transaction) do %>
|
||||
<div class="grid grid-cols-12 items-center text-primary text-sm font-medium p-4 lg:p-4
|
||||
<%= @focused_record == entry || @focused_record == transaction ?
|
||||
"border border-gray-900 rounded-lg" : "" %>">
|
||||
"border border-gray-900 rounded-lg" : "" %>
|
||||
<%= entry.excluded ? "opacity-50 text-gray-400" : "" %>">
|
||||
|
||||
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 lg:col-span-6">
|
||||
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 <%= view_ctx == "global" ? "lg:col-span-8" : "lg:col-span-6" %>">
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
disabled: transaction.transfer?,
|
||||
disabled: transaction.transfer.present?,
|
||||
class: "checkbox checkbox--light",
|
||||
data: {
|
||||
id: entry.id,
|
||||
|
@ -35,33 +36,49 @@
|
|||
|
||||
<div class="truncate">
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= link_to(
|
||||
transaction.transfer? ? transaction.transfer.name : entry.name,
|
||||
transaction.transfer? ? transfer_path(transaction.transfer) : entry_path(entry),
|
||||
data: {
|
||||
turbo_frame: "drawer",
|
||||
turbo_prefetch: false
|
||||
},
|
||||
class: "hover:underline"
|
||||
) %>
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<div class="truncate flex-shrink">
|
||||
<% if transaction.transfer? %>
|
||||
<%= link_to(
|
||||
entry.name,
|
||||
transaction.transfer.present? ? transfer_path(transaction.transfer) : entry_path(entry),
|
||||
data: {
|
||||
turbo_frame: "drawer",
|
||||
turbo_prefetch: false
|
||||
},
|
||||
class: "hover:underline"
|
||||
) %>
|
||||
<% else %>
|
||||
<%= link_to(
|
||||
entry.name,
|
||||
entry_path(entry),
|
||||
data: {
|
||||
turbo_frame: "drawer",
|
||||
turbo_prefetch: false
|
||||
},
|
||||
class: "hover:underline"
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if entry.excluded %>
|
||||
<span class="text-orange-500" title="One-time <%= entry.amount.negative? ? "income" : "expense" %> (excluded from averages)">
|
||||
<%= icon "asterisk", size: "sm", color: "current" %>
|
||||
</span>
|
||||
<% end %>
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<% if transaction.one_time? %>
|
||||
<span class="text-orange-500" title="One-time <%= entry.amount.negative? ? "income" : "expense" %> (excluded from averages)">
|
||||
<%= icon "asterisk", size: "sm", color: "current" %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<% if transaction.transfer? %>
|
||||
<%= render "transactions/transfer_match", transaction: transaction %>
|
||||
<% end %>
|
||||
<% if transaction.transfer.present? %>
|
||||
<%= render "transactions/transfer_match", transaction: transaction %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-secondary text-xs font-normal hidden lg:block">
|
||||
<% if transaction.transfer? %>
|
||||
<%= render "transfers/account_links",
|
||||
transfer: transaction.transfer,
|
||||
is_inflow: transaction.transfer_as_inflow.present? %>
|
||||
<span class="text-secondary">
|
||||
<%= transaction.loan_payment? ? "Loan Payment" : "Transfer" %> • <%= entry.account.name %>
|
||||
</span>
|
||||
<% else %>
|
||||
<%= link_to entry.account.name,
|
||||
account_path(entry.account, tab: "transactions", focused_record_id: entry.id),
|
||||
|
@ -85,14 +102,16 @@
|
|||
class: ["text-green-600": entry.amount.negative?] %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 justify-self-end hidden lg:block">
|
||||
<% if balance_trend&.trend %>
|
||||
<%= tag.p format_money(balance_trend.trend.current),
|
||||
<% if view_ctx != "global" %>
|
||||
<div class="col-span-2 justify-self-end hidden lg:block">
|
||||
<% if balance_trend&.trend %>
|
||||
<%= tag.p format_money(balance_trend.trend.current),
|
||||
class: "font-medium text-sm text-primary" %>
|
||||
<% else %>
|
||||
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<%= render "summary", totals: @totals %>
|
||||
<%= render "summary", totals: @search.totals %>
|
||||
|
||||
<div id="transactions"
|
||||
data-controller="bulk-select"
|
||||
|
|
|
@ -106,13 +106,34 @@
|
|||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary">One-time <%= @entry.amount.negative? ? "Income" : "Expense" %></h4>
|
||||
<p class="text-secondary">One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.</p>
|
||||
<h4 class="text-primary">Exclude</h4>
|
||||
<p class="text-secondary">Excluded transactions will be removed from budgeting calculations and reports.</p>
|
||||
</div>
|
||||
|
||||
<%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: transaction_path(@entry),
|
||||
class: "p-3",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary">One-time <%= @entry.amount.negative? ? "Income" : "Expense" %></h4>
|
||||
<p class="text-secondary">One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.</p>
|
||||
</div>
|
||||
|
||||
<%= ef.toggle :kind, {
|
||||
checked: @entry.transaction.one_time?,
|
||||
data: { auto_submit_form_target: "auto" }
|
||||
}, "one_time", "standard" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
|
|
|
@ -38,5 +38,8 @@ module Maybe
|
|||
config.lookbook.preview_display_options = {
|
||||
theme: [ "light", "dark" ] # available in view as params[:theme]
|
||||
}
|
||||
|
||||
# Enable Rack::Attack middleware for API rate limiting
|
||||
config.middleware.use Rack::Attack
|
||||
end
|
||||
end
|
||||
|
|
|
@ -80,6 +80,29 @@
|
|||
22
|
||||
],
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"warning_type": "Mass Assignment",
|
||||
"warning_code": 105,
|
||||
"fingerprint": "85e2c11853dd6c69b1953a6ec3ad661cd0ce3df55e4e5beff92365b6ed601171",
|
||||
"check_name": "PermitAttributes",
|
||||
"message": "Potentially dangerous key allowed for mass assignment",
|
||||
"file": "app/controllers/api/v1/transactions_controller.rb",
|
||||
"line": 255,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
|
||||
"code": "params.require(:transaction).permit(:account_id, :date, :amount, :name, :description, :notes, :currency, :category_id, :merchant_id, :nature, :tag_ids => ([]))",
|
||||
"render_path": null,
|
||||
"location": {
|
||||
"type": "method",
|
||||
"class": "Api::V1::TransactionsController",
|
||||
"method": "transaction_params"
|
||||
},
|
||||
"user_input": ":account_id",
|
||||
"confidence": "High",
|
||||
"cwe_id": [
|
||||
915
|
||||
],
|
||||
"note": "account_id is properly validated in create action - line 79 ensures account belongs to user's family: family.accounts.find(transaction_params[:account_id])"
|
||||
}
|
||||
],
|
||||
"brakeman_version": "7.0.2"
|
||||
|
|
25
config/initializers/active_record_encryption.rb
Normal file
25
config/initializers/active_record_encryption.rb
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Auto-generate Active Record encryption keys for self-hosted instances
|
||||
# This ensures encryption works out of the box without manual setup
|
||||
if Rails.application.config.app_mode.self_hosted? && !Rails.application.credentials.active_record_encryption.present?
|
||||
# Check if keys are provided via environment variables
|
||||
primary_key = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"]
|
||||
deterministic_key = ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"]
|
||||
key_derivation_salt = ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"]
|
||||
|
||||
# If any key is missing, generate all of them based on SECRET_KEY_BASE
|
||||
if primary_key.blank? || deterministic_key.blank? || key_derivation_salt.blank?
|
||||
# Use SECRET_KEY_BASE as the seed for deterministic key generation
|
||||
# This ensures keys are consistent across container restarts
|
||||
secret_base = Rails.application.secret_key_base
|
||||
|
||||
# Generate deterministic keys from the secret base
|
||||
primary_key = Digest::SHA256.hexdigest("#{secret_base}:primary_key")[0..63]
|
||||
deterministic_key = Digest::SHA256.hexdigest("#{secret_base}:deterministic_key")[0..63]
|
||||
key_derivation_salt = Digest::SHA256.hexdigest("#{secret_base}:key_derivation_salt")[0..63]
|
||||
end
|
||||
|
||||
# Configure Active Record encryption
|
||||
Rails.application.config.active_record.encryption.primary_key = primary_key
|
||||
Rails.application.config.active_record.encryption.deterministic_key = deterministic_key
|
||||
Rails.application.config.active_record.encryption.key_derivation_salt = key_derivation_salt
|
||||
end
|
546
config/initializers/doorkeeper.rb
Normal file
546
config/initializers/doorkeeper.rb
Normal file
|
@ -0,0 +1,546 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Doorkeeper.configure do
|
||||
# Change the ORM that doorkeeper will use (requires ORM extensions installed).
|
||||
# Check the list of supported ORMs here: https://github.com/doorkeeper-gem/doorkeeper#orms
|
||||
orm :active_record
|
||||
|
||||
# This block will be called to check whether the resource owner is authenticated or not.
|
||||
resource_owner_authenticator do
|
||||
# Manually replicate the app's session-based authentication logic, since
|
||||
# Doorkeeper controllers don't include our Authentication concern.
|
||||
if (session_id = cookies.signed[:session_token]).present?
|
||||
if (session_record = Session.find_by(id: session_id))
|
||||
# Set Current.session so downstream code expecting it behaves normally.
|
||||
Current.session = session_record
|
||||
# Return the authenticated user object as the resource owner.
|
||||
session_record.user
|
||||
else
|
||||
redirect_to new_session_url
|
||||
end
|
||||
else
|
||||
redirect_to new_session_url
|
||||
end
|
||||
end
|
||||
|
||||
# If you didn't skip applications controller from Doorkeeper routes in your application routes.rb
|
||||
# file then you need to declare this block in order to restrict access to the web interface for
|
||||
# adding oauth authorized applications. In other case it will return 403 Forbidden response
|
||||
# every time somebody will try to access the admin web interface.
|
||||
#
|
||||
admin_authenticator do
|
||||
if (session_id = cookies.signed[:session_token]).present?
|
||||
if (session_record = Session.find_by(id: session_id))
|
||||
Current.session = session_record
|
||||
head :forbidden unless session_record.user&.super_admin?
|
||||
else
|
||||
redirect_to new_session_url
|
||||
end
|
||||
else
|
||||
redirect_to new_session_url
|
||||
end
|
||||
end
|
||||
|
||||
# You can use your own model classes if you need to extend (or even override) default
|
||||
# Doorkeeper models such as `Application`, `AccessToken` and `AccessGrant.
|
||||
#
|
||||
# By default Doorkeeper ActiveRecord ORM uses its own classes:
|
||||
#
|
||||
# access_token_class "Doorkeeper::AccessToken"
|
||||
# access_grant_class "Doorkeeper::AccessGrant"
|
||||
# application_class "Doorkeeper::Application"
|
||||
#
|
||||
# Don't forget to include Doorkeeper ORM mixins into your custom models:
|
||||
#
|
||||
# * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken - for access token
|
||||
# * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessGrant - for access grant
|
||||
# * ::Doorkeeper::Orm::ActiveRecord::Mixins::Application - for application (OAuth2 clients)
|
||||
#
|
||||
# For example:
|
||||
#
|
||||
# access_token_class "MyAccessToken"
|
||||
#
|
||||
# class MyAccessToken < ApplicationRecord
|
||||
# include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken
|
||||
#
|
||||
# self.table_name = "hey_i_wanna_my_name"
|
||||
#
|
||||
# def destroy_me!
|
||||
# destroy
|
||||
# end
|
||||
# end
|
||||
|
||||
# Enables polymorphic Resource Owner association for Access Tokens and Access Grants.
|
||||
# By default this option is disabled.
|
||||
#
|
||||
# Make sure you properly setup you database and have all the required columns (run
|
||||
# `bundle exec rails generate doorkeeper:enable_polymorphic_resource_owner` and execute Rails
|
||||
# migrations).
|
||||
#
|
||||
# If this option enabled, Doorkeeper will store not only Resource Owner primary key
|
||||
# value, but also it's type (class name). See "Polymorphic Associations" section of
|
||||
# Rails guides: https://guides.rubyonrails.org/association_basics.html#polymorphic-associations
|
||||
#
|
||||
# [NOTE] If you apply this option on already existing project don't forget to manually
|
||||
# update `resource_owner_type` column in the database and fix migration template as it will
|
||||
# set NOT NULL constraint for Access Grants table.
|
||||
#
|
||||
# use_polymorphic_resource_owner
|
||||
|
||||
# If you are planning to use Doorkeeper in Rails 5 API-only application, then you might
|
||||
# want to use API mode that will skip all the views management and change the way how
|
||||
# Doorkeeper responds to a requests.
|
||||
#
|
||||
# api_only
|
||||
|
||||
# Enforce token request content type to application/x-www-form-urlencoded.
|
||||
# It is not enabled by default to not break prior versions of the gem.
|
||||
#
|
||||
# enforce_content_type
|
||||
|
||||
# Authorization Code expiration time (default: 10 minutes).
|
||||
#
|
||||
# authorization_code_expires_in 10.minutes
|
||||
|
||||
# Access token expiration time (default: 2 hours).
|
||||
# If you set this to `nil` Doorkeeper will not expire the token and omit expires_in in response.
|
||||
# It is RECOMMENDED to set expiration time explicitly.
|
||||
# Prefer access_token_expires_in 100.years or similar,
|
||||
# which would be functionally equivalent and avoid the risk of unexpected behavior by callers.
|
||||
#
|
||||
access_token_expires_in 1.year
|
||||
|
||||
# Assign custom TTL for access tokens. Will be used instead of access_token_expires_in
|
||||
# option if defined. In case the block returns `nil` value Doorkeeper fallbacks to
|
||||
# +access_token_expires_in+ configuration option value. If you really need to issue a
|
||||
# non-expiring access token (which is not recommended) then you need to return
|
||||
# Float::INFINITY from this block.
|
||||
#
|
||||
# `context` has the following properties available:
|
||||
#
|
||||
# * `client` - the OAuth client application (see Doorkeeper::OAuth::Client)
|
||||
# * `grant_type` - the grant type of the request (see Doorkeeper::OAuth)
|
||||
# * `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes)
|
||||
# * `resource_owner` - authorized resource owner instance (if present)
|
||||
#
|
||||
# custom_access_token_expires_in do |context|
|
||||
# context.client.additional_settings.implicit_oauth_expiration
|
||||
# end
|
||||
|
||||
# Use a custom class for generating the access token.
|
||||
# See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-access-token-generator
|
||||
#
|
||||
# access_token_generator '::Doorkeeper::JWT'
|
||||
|
||||
# The controller +Doorkeeper::ApplicationController+ inherits from.
|
||||
# Defaults to +ActionController::Base+ unless +api_only+ is set, which changes the default to
|
||||
# +ActionController::API+. The return value of this option must be a stringified class name.
|
||||
# See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-controllers
|
||||
#
|
||||
# base_controller 'ApplicationController'
|
||||
|
||||
# Reuse access token for the same resource owner within an application (disabled by default).
|
||||
#
|
||||
# This option protects your application from creating new tokens before old **valid** one becomes
|
||||
# expired so your database doesn't bloat. Keep in mind that when this option is enabled Doorkeeper
|
||||
# doesn't update existing token expiration time, it will create a new token instead if no active matching
|
||||
# token found for the application, resources owner and/or set of scopes.
|
||||
# Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383
|
||||
#
|
||||
# You can not enable this option together with +hash_token_secrets+.
|
||||
#
|
||||
# reuse_access_token
|
||||
|
||||
# In case you enabled `reuse_access_token` option Doorkeeper will try to find matching
|
||||
# token using `matching_token_for` Access Token API that searches for valid records
|
||||
# in batches in order not to pollute the memory with all the database records. By default
|
||||
# Doorkeeper uses batch size of 10 000 records. You can increase or decrease this value
|
||||
# depending on your needs and server capabilities.
|
||||
#
|
||||
# token_lookup_batch_size 10_000
|
||||
|
||||
# Set a limit for token_reuse if using reuse_access_token option
|
||||
#
|
||||
# This option limits token_reusability to some extent.
|
||||
# If not set then access_token will be reused unless it expires.
|
||||
# Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/1189
|
||||
#
|
||||
# This option should be a percentage(i.e. (0,100])
|
||||
#
|
||||
# token_reuse_limit 100
|
||||
|
||||
# Only allow one valid access token obtained via client credentials
|
||||
# per client. If a new access token is obtained before the old one
|
||||
# expired, the old one gets revoked (disabled by default)
|
||||
#
|
||||
# When enabling this option, make sure that you do not expect multiple processes
|
||||
# using the same credentials at the same time (e.g. web servers spanning
|
||||
# multiple machines and/or processes).
|
||||
#
|
||||
# revoke_previous_client_credentials_token
|
||||
|
||||
# Only allow one valid access token obtained via authorization code
|
||||
# per client. If a new access token is obtained before the old one
|
||||
# expired, the old one gets revoked (disabled by default)
|
||||
#
|
||||
# revoke_previous_authorization_code_token
|
||||
|
||||
# Require non-confidential clients to use PKCE when using an authorization code
|
||||
# to obtain an access_token (disabled by default)
|
||||
#
|
||||
force_pkce
|
||||
|
||||
# Hash access and refresh tokens before persisting them.
|
||||
# This will disable the possibility to use +reuse_access_token+
|
||||
# since plain values can no longer be retrieved.
|
||||
#
|
||||
# Note: If you are already a user of doorkeeper and have existing tokens
|
||||
# in your installation, they will be invalid without adding 'fallback: :plain'.
|
||||
#
|
||||
# For test environment, allow fallback to plain tokens to make testing easier
|
||||
if Rails.env.test?
|
||||
hash_token_secrets fallback: :plain
|
||||
else
|
||||
hash_token_secrets
|
||||
end
|
||||
# By default, token secrets will be hashed using the
|
||||
# +Doorkeeper::Hashing::SHA256+ strategy.
|
||||
#
|
||||
# If you wish to use another hashing implementation, you can override
|
||||
# this strategy as follows:
|
||||
#
|
||||
# hash_token_secrets using: '::Doorkeeper::Hashing::MyCustomHashImpl'
|
||||
#
|
||||
# Keep in mind that changing the hashing function will invalidate all existing
|
||||
# secrets, if there are any.
|
||||
|
||||
# Hash application secrets before persisting them.
|
||||
#
|
||||
hash_application_secrets
|
||||
#
|
||||
# By default, applications will be hashed
|
||||
# with the +Doorkeeper::SecretStoring::SHA256+ strategy.
|
||||
#
|
||||
# If you wish to use bcrypt for application secret hashing, uncomment
|
||||
# this line instead:
|
||||
#
|
||||
# hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt'
|
||||
|
||||
# When the above option is enabled, and a hashed token or secret is not found,
|
||||
# you can allow to fall back to another strategy. For users upgrading
|
||||
# doorkeeper and wishing to enable hashing, you will probably want to enable
|
||||
# the fallback to plain tokens.
|
||||
#
|
||||
# This will ensure that old access tokens and secrets
|
||||
# will remain valid even if the hashing above is enabled.
|
||||
#
|
||||
# This can be done by adding 'fallback: plain', e.g. :
|
||||
#
|
||||
# hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt', fallback: :plain
|
||||
|
||||
# Issue access tokens with refresh token (disabled by default), you may also
|
||||
# pass a block which accepts `context` to customize when to give a refresh
|
||||
# token or not. Similar to +custom_access_token_expires_in+, `context` has
|
||||
# the following properties:
|
||||
#
|
||||
# `client` - the OAuth client application (see Doorkeeper::OAuth::Client)
|
||||
# `grant_type` - the grant type of the request (see Doorkeeper::OAuth)
|
||||
# `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes)
|
||||
#
|
||||
use_refresh_token
|
||||
|
||||
# Provide support for an owner to be assigned to each registered application (disabled by default)
|
||||
# Optional parameter confirmation: true (default: false) if you want to enforce ownership of
|
||||
# a registered application
|
||||
# NOTE: you must also run the rails g doorkeeper:application_owner generator
|
||||
# to provide the necessary support
|
||||
#
|
||||
enable_application_owner confirmation: false
|
||||
|
||||
# Define access token scopes for your provider
|
||||
# For more information go to
|
||||
# https://doorkeeper.gitbook.io/guides/ruby-on-rails/scopes
|
||||
#
|
||||
default_scopes :read
|
||||
optional_scopes :read_write
|
||||
|
||||
# Allows to restrict only certain scopes for grant_type.
|
||||
# By default, all the scopes will be available for all the grant types.
|
||||
#
|
||||
# Keys to this hash should be the name of grant_type and
|
||||
# values should be the array of scopes for that grant type.
|
||||
# Note: scopes should be from configured_scopes (i.e. default or optional)
|
||||
#
|
||||
# scopes_by_grant_type password: [:write], client_credentials: [:update]
|
||||
|
||||
# Forbids creating/updating applications with arbitrary scopes that are
|
||||
# not in configuration, i.e. +default_scopes+ or +optional_scopes+.
|
||||
# (disabled by default)
|
||||
#
|
||||
# enforce_configured_scopes
|
||||
|
||||
# Change the way client credentials are retrieved from the request object.
|
||||
# By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
|
||||
# falls back to the `:client_id` and `:client_secret` params from the `params` object.
|
||||
# Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated
|
||||
# for more information on customization
|
||||
#
|
||||
# client_credentials :from_basic, :from_params
|
||||
|
||||
# Change the way access token is authenticated from the request object.
|
||||
# By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
|
||||
# falls back to the `:access_token` or `:bearer_token` params from the `params` object.
|
||||
# Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated
|
||||
# for more information on customization
|
||||
#
|
||||
# access_token_methods :from_bearer_authorization, :from_access_token_param, :from_bearer_param
|
||||
|
||||
# Forces the usage of the HTTPS protocol in non-native redirect uris (enabled
|
||||
# by default in non-development environments). OAuth2 delegates security in
|
||||
# communication to the HTTPS protocol so it is wise to keep this enabled.
|
||||
#
|
||||
# Callable objects such as proc, lambda, block or any object that responds to
|
||||
# #call can be used in order to allow conditional checks (to allow non-SSL
|
||||
# redirects to localhost for example).
|
||||
#
|
||||
# Allow custom URL schemes for mobile apps
|
||||
force_ssl_in_redirect_uri false
|
||||
|
||||
# Specify what redirect URI's you want to block during Application creation.
|
||||
# Any redirect URI is allowed by default.
|
||||
#
|
||||
# You can use this option in order to forbid URI's with 'javascript' scheme
|
||||
# for example.
|
||||
#
|
||||
# Block javascript URIs but allow custom schemes
|
||||
forbid_redirect_uri { |uri| uri.scheme.to_s.downcase == "javascript" }
|
||||
|
||||
# Allows to set blank redirect URIs for Applications in case Doorkeeper configured
|
||||
# to use URI-less OAuth grant flows like Client Credentials or Resource Owner
|
||||
# Password Credentials. The option is on by default and checks configured grant
|
||||
# types, but you **need** to manually drop `NOT NULL` constraint from `redirect_uri`
|
||||
# column for `oauth_applications` database table.
|
||||
#
|
||||
# You can completely disable this feature with:
|
||||
#
|
||||
# allow_blank_redirect_uri false
|
||||
#
|
||||
# Or you can define your custom check:
|
||||
#
|
||||
# allow_blank_redirect_uri do |grant_flows, client|
|
||||
# client.superapp?
|
||||
# end
|
||||
|
||||
# Specify how authorization errors should be handled.
|
||||
# By default, doorkeeper renders json errors when access token
|
||||
# is invalid, expired, revoked or has invalid scopes.
|
||||
#
|
||||
# If you want to render error response yourself (i.e. rescue exceptions),
|
||||
# set +handle_auth_errors+ to `:raise` and rescue Doorkeeper::Errors::InvalidToken
|
||||
# or following specific errors:
|
||||
#
|
||||
# Doorkeeper::Errors::TokenForbidden, Doorkeeper::Errors::TokenExpired,
|
||||
# Doorkeeper::Errors::TokenRevoked, Doorkeeper::Errors::TokenUnknown
|
||||
#
|
||||
# handle_auth_errors :raise
|
||||
#
|
||||
# If you want to redirect back to the client application in accordance with
|
||||
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, you can set
|
||||
# +handle_auth_errors+ to :redirect
|
||||
#
|
||||
# handle_auth_errors :redirect
|
||||
|
||||
# Customize token introspection response.
|
||||
# Allows to add your own fields to default one that are required by the OAuth spec
|
||||
# for the introspection response. It could be `sub`, `aud` and so on.
|
||||
# This configuration option can be a proc, lambda or any Ruby object responds
|
||||
# to `.call` method and result of it's invocation must be a Hash.
|
||||
#
|
||||
# custom_introspection_response do |token, context|
|
||||
# {
|
||||
# "sub": "Z5O3upPC88QrAjx00dis",
|
||||
# "aud": "https://protected.example.net/resource",
|
||||
# "username": User.find(token.resource_owner_id).username
|
||||
# }
|
||||
# end
|
||||
#
|
||||
# or
|
||||
#
|
||||
# custom_introspection_response CustomIntrospectionResponder
|
||||
|
||||
# Specify what grant flows are enabled in array of Strings. The valid
|
||||
# strings and the flows they enable are:
|
||||
#
|
||||
# "authorization_code" => Authorization Code Grant Flow
|
||||
# "implicit" => Implicit Grant Flow
|
||||
# "password" => Resource Owner Password Credentials Grant Flow
|
||||
# "client_credentials" => Client Credentials Grant Flow
|
||||
#
|
||||
# If not specified, Doorkeeper enables authorization_code and
|
||||
# client_credentials.
|
||||
#
|
||||
# implicit and password grant flows have risks that you should understand
|
||||
# before enabling:
|
||||
# https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.2
|
||||
# https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.3
|
||||
#
|
||||
# grant_flows %w[authorization_code client_credentials]
|
||||
|
||||
# Allows to customize OAuth grant flows that +each+ application support.
|
||||
# You can configure a custom block (or use a class respond to `#call`) that must
|
||||
# return `true` in case Application instance supports requested OAuth grant flow
|
||||
# during the authorization request to the server. This configuration +doesn't+
|
||||
# set flows per application, it only allows to check if application supports
|
||||
# specific grant flow.
|
||||
#
|
||||
# For example you can add an additional database column to `oauth_applications` table,
|
||||
# say `t.array :grant_flows, default: []`, and store allowed grant flows that can
|
||||
# be used with this application there. Then when authorization requested Doorkeeper
|
||||
# will call this block to check if specific Application (passed with client_id and/or
|
||||
# client_secret) is allowed to perform the request for the specific grant type
|
||||
# (authorization, password, client_credentials, etc).
|
||||
#
|
||||
# Example of the block:
|
||||
#
|
||||
# ->(flow, client) { client.grant_flows.include?(flow) }
|
||||
#
|
||||
# In case this option invocation result is `false`, Doorkeeper server returns
|
||||
# :unauthorized_client error and stops the request.
|
||||
#
|
||||
# @param allow_grant_flow_for_client [Proc] Block or any object respond to #call
|
||||
# @return [Boolean] `true` if allow or `false` if forbid the request
|
||||
#
|
||||
# allow_grant_flow_for_client do |grant_flow, client|
|
||||
# # `grant_flows` is an Array column with grant
|
||||
# # flows that application supports
|
||||
#
|
||||
# client.grant_flows.include?(grant_flow)
|
||||
# end
|
||||
|
||||
# If you need arbitrary Resource Owner-Client authorization you can enable this option
|
||||
# and implement the check your need. Config option must respond to #call and return
|
||||
# true in case resource owner authorized for the specific application or false in other
|
||||
# cases.
|
||||
#
|
||||
# By default all Resource Owners are authorized to any Client (application).
|
||||
#
|
||||
# authorize_resource_owner_for_client do |client, resource_owner|
|
||||
# resource_owner.admin? || client.owners_allowlist.include?(resource_owner)
|
||||
# end
|
||||
|
||||
# Allows additional data fields to be sent while granting access to an application,
|
||||
# and for this additional data to be included in subsequently generated access tokens.
|
||||
# The 'authorizations/new' page will need to be overridden to include this additional data
|
||||
# in the request params when granting access. The access grant and access token models
|
||||
# will both need to respond to these additional data fields, and have a database column
|
||||
# to store them in.
|
||||
#
|
||||
# Example:
|
||||
# You have a multi-tenanted platform and want to be able to grant access to a specific
|
||||
# tenant, rather than all the tenants a user has access to. You can use this config
|
||||
# option to specify that a ':tenant_id' will be passed when authorizing. This tenant_id
|
||||
# will be included in the access tokens. When a request is made with one of these access
|
||||
# tokens, you can check that the requested data belongs to the specified tenant.
|
||||
#
|
||||
# Default value is an empty Array: []
|
||||
# custom_access_token_attributes [:tenant_id]
|
||||
|
||||
# Hook into the strategies' request & response life-cycle in case your
|
||||
# application needs advanced customization or logging:
|
||||
#
|
||||
# before_successful_strategy_response do |request|
|
||||
# puts "BEFORE HOOK FIRED! #{request}"
|
||||
# end
|
||||
#
|
||||
# after_successful_strategy_response do |request, response|
|
||||
# puts "AFTER HOOK FIRED! #{request}, #{response}"
|
||||
# end
|
||||
|
||||
# Hook into Authorization flow in order to implement Single Sign Out
|
||||
# or add any other functionality. Inside the block you have an access
|
||||
# to `controller` (authorizations controller instance) and `context`
|
||||
# (Doorkeeper::OAuth::Hooks::Context instance) which provides pre auth
|
||||
# or auth objects with issued token based on hook type (before or after).
|
||||
#
|
||||
# before_successful_authorization do |controller, context|
|
||||
# Rails.logger.info(controller.request.params.inspect)
|
||||
#
|
||||
# Rails.logger.info(context.pre_auth.inspect)
|
||||
# end
|
||||
#
|
||||
# after_successful_authorization do |controller, context|
|
||||
# controller.session[:logout_urls] <<
|
||||
# Doorkeeper::Application
|
||||
# .find_by(controller.request.params.slice(:redirect_uri))
|
||||
# .logout_uri
|
||||
#
|
||||
# Rails.logger.info(context.auth.inspect)
|
||||
# Rails.logger.info(context.issued_token)
|
||||
# end
|
||||
|
||||
# Under some circumstances you might want to have applications auto-approved,
|
||||
# so that the user skips the authorization step.
|
||||
# For example if dealing with a trusted application.
|
||||
#
|
||||
# skip_authorization do |resource_owner, client|
|
||||
# client.superapp? or resource_owner.admin?
|
||||
# end
|
||||
|
||||
# Configure custom constraints for the Token Introspection request.
|
||||
# By default this configuration option allows to introspect a token by another
|
||||
# token of the same application, OR to introspect the token that belongs to
|
||||
# authorized client (from authenticated client) OR when token doesn't
|
||||
# belong to any client (public token). Otherwise requester has no access to the
|
||||
# introspection and it will return response as stated in the RFC.
|
||||
#
|
||||
# Block arguments:
|
||||
#
|
||||
# @param token [Doorkeeper::AccessToken]
|
||||
# token to be introspected
|
||||
#
|
||||
# @param authorized_client [Doorkeeper::Application]
|
||||
# authorized client (if request is authorized using Basic auth with
|
||||
# Client Credentials for example)
|
||||
#
|
||||
# @param authorized_token [Doorkeeper::AccessToken]
|
||||
# Bearer token used to authorize the request
|
||||
#
|
||||
# In case the block returns `nil` or `false` introspection responses with 401 status code
|
||||
# when using authorized token to introspect, or you'll get 200 with { "active": false } body
|
||||
# when using authorized client to introspect as stated in the
|
||||
# RFC 7662 section 2.2. Introspection Response.
|
||||
#
|
||||
# Using with caution:
|
||||
# Keep in mind that these three parameters pass to block can be nil as following case:
|
||||
# `authorized_client` is nil if and only if `authorized_token` is present, and vice versa.
|
||||
# `token` will be nil if and only if `authorized_token` is present.
|
||||
# So remember to use `&` or check if it is present before calling method on
|
||||
# them to make sure you doesn't get NoMethodError exception.
|
||||
#
|
||||
# You can define your custom check:
|
||||
#
|
||||
# allow_token_introspection do |token, authorized_client, authorized_token|
|
||||
# if authorized_token
|
||||
# # customize: require `introspection` scope
|
||||
# authorized_token.application == token&.application ||
|
||||
# authorized_token.scopes.include?("introspection")
|
||||
# elsif token.application
|
||||
# # `protected_resource` is a new database boolean column, for example
|
||||
# authorized_client == token.application || authorized_client.protected_resource?
|
||||
# else
|
||||
# # public token (when token.application is nil, token doesn't belong to any application)
|
||||
# true
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# Or you can completely disable any token introspection:
|
||||
#
|
||||
# allow_token_introspection false
|
||||
#
|
||||
# If you need to block the request at all, then configure your routes.rb or web-server
|
||||
# like nginx to forbid the request.
|
||||
|
||||
# WWW-Authenticate Realm (default: "Doorkeeper").
|
||||
#
|
||||
# realm "Doorkeeper"
|
||||
end
|
20
config/initializers/doorkeeper_csrf_protection.rb
Normal file
20
config/initializers/doorkeeper_csrf_protection.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
# Disable CSRF protection for Doorkeeper endpoints.
|
||||
#
|
||||
# OAuth requests (both the authorization endpoint hit by users and the token
|
||||
# endpoint hit by confidential/public clients) are performed by third-party
|
||||
# clients that do not have access to the Rails session, and therefore cannot
|
||||
# include the standard CSRF token. Requiring the token in these controllers
|
||||
# breaks the OAuth flow with an ActionController::InvalidAuthenticityToken
|
||||
# error. It is safe to disable CSRF verification here because Doorkeeper's
|
||||
# endpoints already implement their own security semantics defined by the
|
||||
# OAuth 2.0 specification (PKCE, client/secret checks, etc.).
|
||||
#
|
||||
# This hook runs on each application reload in development and ensures the
|
||||
# callback is applied after Doorkeeper loads its controllers.
|
||||
Rails.application.config.to_prepare do
|
||||
# Doorkeeper::ApplicationController is the base controller for all
|
||||
# Doorkeeper-provided controllers (AuthorizationsController, TokensController,
|
||||
# TokenInfoController, etc.). Removing the authenticity-token filter here
|
||||
# cascades to all of them.
|
||||
Doorkeeper::ApplicationController.skip_forgery_protection
|
||||
end
|
6
config/initializers/doorkeeper_layout.rb
Normal file
6
config/initializers/doorkeeper_layout.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
# Ensure Doorkeeper controllers use the correct layout
|
||||
Rails.application.config.to_prepare do
|
||||
Doorkeeper::AuthorizationsController.layout "doorkeeper/application"
|
||||
Doorkeeper::AuthorizedApplicationsController.layout "doorkeeper/application"
|
||||
Doorkeeper::ApplicationsController.layout "doorkeeper/application"
|
||||
end
|
66
config/initializers/rack_attack.rb
Normal file
66
config/initializers/rack_attack.rb
Normal file
|
@ -0,0 +1,66 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Rack::Attack
|
||||
# Enable Rack::Attack
|
||||
enabled = Rails.env.production? || Rails.env.staging?
|
||||
|
||||
# Throttle requests to the OAuth token endpoint
|
||||
throttle("oauth/token", limit: 10, period: 1.minute) do |request|
|
||||
request.ip if request.path == "/oauth/token"
|
||||
end
|
||||
|
||||
# Throttle API requests per access token
|
||||
throttle("api/requests", limit: 100, period: 1.hour) do |request|
|
||||
if request.path.start_with?("/api/")
|
||||
# Extract access token from Authorization header
|
||||
auth_header = request.get_header("HTTP_AUTHORIZATION")
|
||||
if auth_header&.start_with?("Bearer ")
|
||||
token = auth_header.split(" ").last
|
||||
"api_token:#{Digest::SHA256.hexdigest(token)}"
|
||||
else
|
||||
# Fall back to IP-based limiting for unauthenticated requests
|
||||
"api_ip:#{request.ip}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# More permissive throttling for API requests by IP (for development/testing)
|
||||
throttle("api/ip", limit: 200, period: 1.hour) do |request|
|
||||
request.ip if request.path.start_with?("/api/")
|
||||
end
|
||||
|
||||
# Block requests that appear to be malicious
|
||||
blocklist("block malicious requests") do |request|
|
||||
# Block requests with suspicious user agents
|
||||
suspicious_user_agents = [
|
||||
/sqlmap/i,
|
||||
/nmap/i,
|
||||
/nikto/i,
|
||||
/masscan/i
|
||||
]
|
||||
|
||||
user_agent = request.user_agent
|
||||
suspicious_user_agents.any? { |pattern| user_agent =~ pattern } if user_agent
|
||||
end
|
||||
|
||||
# Configure response for throttled requests
|
||||
self.throttled_responder = lambda do |request|
|
||||
[
|
||||
429, # status
|
||||
{
|
||||
"Content-Type" => "application/json",
|
||||
"Retry-After" => "60"
|
||||
},
|
||||
[ { error: "Rate limit exceeded. Try again later." }.to_json ]
|
||||
]
|
||||
end
|
||||
|
||||
# Configure response for blocked requests
|
||||
self.blocklisted_responder = lambda do |request|
|
||||
[
|
||||
403, # status
|
||||
{ "Content-Type" => "application/json" },
|
||||
[ { error: "Request blocked." }.to_json ]
|
||||
]
|
||||
end
|
||||
end
|
155
config/locales/doorkeeper.en.yml
Normal file
155
config/locales/doorkeeper.en.yml
Normal file
|
@ -0,0 +1,155 @@
|
|||
en:
|
||||
activerecord:
|
||||
attributes:
|
||||
doorkeeper/application:
|
||||
name: 'Name'
|
||||
redirect_uri: 'Redirect URI'
|
||||
errors:
|
||||
models:
|
||||
doorkeeper/application:
|
||||
attributes:
|
||||
redirect_uri:
|
||||
fragment_present: 'cannot contain a fragment.'
|
||||
invalid_uri: 'must be a valid URI.'
|
||||
unspecified_scheme: 'must specify a scheme.'
|
||||
relative_uri: 'must be an absolute URI.'
|
||||
secured_uri: 'must be an HTTPS/SSL URI.'
|
||||
forbidden_uri: 'is forbidden by the server.'
|
||||
scopes:
|
||||
not_match_configured: "doesn't match configured on the server."
|
||||
|
||||
doorkeeper:
|
||||
applications:
|
||||
confirmations:
|
||||
destroy: 'Are you sure?'
|
||||
buttons:
|
||||
edit: 'Edit'
|
||||
destroy: 'Destroy'
|
||||
submit: 'Submit'
|
||||
cancel: 'Cancel'
|
||||
authorize: 'Authorize'
|
||||
form:
|
||||
error: 'Whoops! Check your form for possible errors'
|
||||
help:
|
||||
confidential: 'Application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.'
|
||||
redirect_uri: 'Use one line per URI'
|
||||
blank_redirect_uri: "Leave it blank if you configured your provider to use Client Credentials, Resource Owner Password Credentials or any other grant type that doesn't require redirect URI."
|
||||
scopes: 'Separate scopes with spaces. Leave blank to use the default scopes.'
|
||||
edit:
|
||||
title: 'Edit application'
|
||||
index:
|
||||
title: 'Your applications'
|
||||
new: 'New Application'
|
||||
name: 'Name'
|
||||
callback_url: 'Callback URL'
|
||||
confidential: 'Confidential?'
|
||||
actions: 'Actions'
|
||||
confidentiality:
|
||||
'yes': 'Yes'
|
||||
'no': 'No'
|
||||
new:
|
||||
title: 'New Application'
|
||||
show:
|
||||
title: 'Application: %{name}'
|
||||
application_id: 'UID'
|
||||
secret: 'Secret'
|
||||
secret_hashed: 'Secret hashed'
|
||||
scopes: 'Scopes'
|
||||
confidential: 'Confidential'
|
||||
callback_urls: 'Callback urls'
|
||||
actions: 'Actions'
|
||||
not_defined: 'Not defined'
|
||||
|
||||
authorizations:
|
||||
buttons:
|
||||
authorize: 'Authorize'
|
||||
deny: 'Deny'
|
||||
error:
|
||||
title: 'An error has occurred'
|
||||
new:
|
||||
title: 'Authorization required'
|
||||
prompt: 'Authorize %{client_name} to use your account?'
|
||||
able_to: 'This application will be able to'
|
||||
show:
|
||||
title: 'Authorization code'
|
||||
form_post:
|
||||
title: 'Submit this form'
|
||||
|
||||
authorized_applications:
|
||||
confirmations:
|
||||
revoke: 'Are you sure?'
|
||||
buttons:
|
||||
revoke: 'Revoke'
|
||||
index:
|
||||
title: 'Your authorized applications'
|
||||
application: 'Application'
|
||||
created_at: 'Created At'
|
||||
date_format: '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
pre_authorization:
|
||||
status: 'Pre-authorization'
|
||||
|
||||
errors:
|
||||
messages:
|
||||
# Common error messages
|
||||
invalid_request:
|
||||
unknown: 'The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.'
|
||||
missing_param: 'Missing required parameter: %{value}.'
|
||||
request_not_authorized: 'Request need to be authorized. Required parameter for authorizing request is missing or invalid.'
|
||||
invalid_code_challenge: 'Code challenge is required.'
|
||||
invalid_redirect_uri: "The requested redirect uri is malformed or doesn't match client redirect URI."
|
||||
unauthorized_client: 'The client is not authorized to perform this request using this method.'
|
||||
access_denied: 'The resource owner or authorization server denied the request.'
|
||||
invalid_scope: 'The requested scope is invalid, unknown, or malformed.'
|
||||
invalid_code_challenge_method:
|
||||
zero: 'The authorization server does not support PKCE as there are no accepted code_challenge_method values.'
|
||||
one: 'The code_challenge_method must be %{challenge_methods}.'
|
||||
other: 'The code_challenge_method must be one of %{challenge_methods}.'
|
||||
server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.'
|
||||
temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.'
|
||||
|
||||
# Configuration error messages
|
||||
credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.'
|
||||
resource_owner_authenticator_not_configured: 'Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfigured.'
|
||||
admin_authenticator_not_configured: 'Access to admin panel is forbidden due to Doorkeeper.configure.admin_authenticator being unconfigured.'
|
||||
|
||||
# Access grant errors
|
||||
unsupported_response_type: 'The authorization server does not support this response type.'
|
||||
unsupported_response_mode: 'The authorization server does not support this response mode.'
|
||||
|
||||
# Access token errors
|
||||
invalid_client: 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.'
|
||||
invalid_grant: 'The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.'
|
||||
unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.'
|
||||
|
||||
invalid_token:
|
||||
revoked: "The access token was revoked"
|
||||
expired: "The access token expired"
|
||||
unknown: "The access token is invalid"
|
||||
revoke:
|
||||
unauthorized: "You are not authorized to revoke this token"
|
||||
|
||||
forbidden_token:
|
||||
missing_scope: 'Access to this resource requires scope "%{oauth_scopes}".'
|
||||
|
||||
flash:
|
||||
applications:
|
||||
create:
|
||||
notice: 'Application created.'
|
||||
destroy:
|
||||
notice: 'Application deleted.'
|
||||
update:
|
||||
notice: 'Application updated.'
|
||||
authorized_applications:
|
||||
destroy:
|
||||
notice: 'Application revoked.'
|
||||
|
||||
layouts:
|
||||
admin:
|
||||
title: 'Doorkeeper'
|
||||
nav:
|
||||
oauth2_provider: 'OAuth2 Provider'
|
||||
applications: 'Applications'
|
||||
home: 'Home'
|
||||
application:
|
||||
title: 'OAuth authorization required'
|
75
config/locales/views/settings/api_keys/en.yml
Normal file
75
config/locales/views/settings/api_keys/en.yml
Normal file
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
en:
|
||||
settings:
|
||||
api_keys_controller:
|
||||
success: "Your API key has been created successfully"
|
||||
revoked_successfully: "API key has been revoked successfully"
|
||||
revoke_failed: "Failed to revoke API key"
|
||||
scope_descriptions:
|
||||
read_accounts: "View Accounts"
|
||||
read_transactions: "View Transactions"
|
||||
read_balances: "View Balances"
|
||||
write_transactions: "Create Transactions"
|
||||
api_keys:
|
||||
show:
|
||||
title: "API Key Management"
|
||||
no_api_key:
|
||||
title: "Create Your API Key"
|
||||
description: "Get programmatic access to your Maybe data with a secure API key."
|
||||
what_you_can_do: "What you can do with the API:"
|
||||
feature_1: "Access your account data programmatically"
|
||||
feature_2: "Build custom integrations and applications"
|
||||
feature_3: "Automate data retrieval and analysis"
|
||||
security_note_title: "Security First"
|
||||
security_note: "Your API key will have restricted permissions based on the scopes you select. You can only have one active API key at a time."
|
||||
create_api_key: "Create API Key"
|
||||
current_api_key:
|
||||
title: "Your API Key"
|
||||
description: "Your active API key is ready to use. Keep it secure and never share it publicly."
|
||||
active: "Active"
|
||||
key_name: "Name"
|
||||
created_at: "Created"
|
||||
last_used: "Last Used"
|
||||
expires: "Expires"
|
||||
ago: "ago"
|
||||
never_used: "Never used"
|
||||
never_expires: "Never expires"
|
||||
permissions: "Permissions"
|
||||
usage_instructions_title: "How to use your API key"
|
||||
usage_instructions: "Include your API key in the X-Api-Key header when making requests to the Maybe API:"
|
||||
regenerate_key: "Create New Key"
|
||||
revoke_key: "Revoke Key"
|
||||
revoke_confirmation: "Are you sure you want to revoke this API key? This action cannot be undone and will immediately disable all applications using this key."
|
||||
new:
|
||||
title: "Create API Key"
|
||||
create_new_key: "Create New API Key"
|
||||
description: "Configure your new API key with a descriptive name and appropriate permissions."
|
||||
name_label: "API Key Name"
|
||||
name_placeholder: "e.g., Production App, Analytics Dashboard"
|
||||
name_help: "Choose a descriptive name to help you identify this key's purpose."
|
||||
permissions_label: "Permissions"
|
||||
permissions_help: "Select the permissions your API key needs. You can always create a new key with different permissions."
|
||||
scope_details:
|
||||
read_accounts: "View account information, balances, and account-level data"
|
||||
read_transactions: "View transaction data, categories, and transaction details"
|
||||
read_balances: "View historical balance data and account value trends"
|
||||
write_transactions: "Create and update transaction records (coming soon)"
|
||||
security_warning_title: "Important Security Notice"
|
||||
security_warning: "Your API key will be shown only once after creation. Store it securely and never share it publicly. If you lose it, you'll need to create a new one."
|
||||
create_key: "Create API Key"
|
||||
cancel: "Cancel"
|
||||
created:
|
||||
title: "API Key Created"
|
||||
success_title: "API Key Created Successfully"
|
||||
success_description: "Your new API key is ready to use. Make sure to copy it now as you won't be able to see it again."
|
||||
your_api_key: "Your API Key"
|
||||
key_name: "Name"
|
||||
permissions: "Permissions"
|
||||
critical_warning_title: "⚠️ Critical: Save Your API Key Now"
|
||||
critical_warning_1: "This is the only time you'll see your API key in plain text."
|
||||
critical_warning_2: "Copy and store it securely in your password manager or application."
|
||||
critical_warning_3: "If you lose this key, you'll need to create a new one."
|
||||
usage_instructions_title: "Quick Start"
|
||||
usage_instructions: "Use your API key by including it in the X-Api-Key header:"
|
||||
copy_key: "Copy API Key"
|
||||
continue: "Continue to API Key Settings"
|
|
@ -70,6 +70,7 @@ en:
|
|||
page_title: Security
|
||||
settings_nav:
|
||||
accounts_label: Accounts
|
||||
api_key_label: API Key
|
||||
billing_label: Billing
|
||||
categories_label: Categories
|
||||
feedback_label: Feedback
|
||||
|
|
|
@ -2,6 +2,7 @@ require "sidekiq/web"
|
|||
require "sidekiq/cron/web"
|
||||
|
||||
Rails.application.routes.draw do
|
||||
use_doorkeeper
|
||||
# MFA routes
|
||||
resource :mfa, controller: "mfa", only: [ :new, :create ] do
|
||||
get :verify
|
||||
|
@ -55,6 +56,7 @@ Rails.application.routes.draw do
|
|||
end
|
||||
resource :billing, only: :show
|
||||
resource :security, only: :show
|
||||
resource :api_key, only: [ :show, :new, :create, :destroy ]
|
||||
end
|
||||
|
||||
resource :subscription, only: %i[new show create] do
|
||||
|
@ -180,6 +182,38 @@ Rails.application.routes.draw do
|
|||
get :accept, on: :member
|
||||
end
|
||||
|
||||
# API routes
|
||||
namespace :api do
|
||||
namespace :v1 do
|
||||
# Authentication endpoints
|
||||
post "auth/signup", to: "auth#signup"
|
||||
post "auth/login", to: "auth#login"
|
||||
post "auth/refresh", to: "auth#refresh"
|
||||
|
||||
# Production API endpoints
|
||||
resources :accounts, only: [ :index ]
|
||||
resources :transactions, only: [ :index, :show, :create, :update, :destroy ]
|
||||
resource :usage, only: [ :show ], controller: "usage"
|
||||
|
||||
resources :chats, only: [ :index, :show, :create, :update, :destroy ] do
|
||||
resources :messages, only: [ :create ] do
|
||||
post :retry, on: :collection
|
||||
end
|
||||
end
|
||||
|
||||
# Test routes for API controller testing (only available in test environment)
|
||||
if Rails.env.test?
|
||||
get "test", to: "test#index"
|
||||
get "test_not_found", to: "test#not_found"
|
||||
get "test_family_access", to: "test#family_access"
|
||||
get "test_scope_required", to: "test#scope_required"
|
||||
get "test_multiple_scopes_required", to: "test#multiple_scopes_required"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
resources :currencies, only: %i[show]
|
||||
|
||||
resources :impersonation_sessions, only: [ :create ] do
|
||||
|
|
99
db/migrate/20250612150749_create_doorkeeper_tables.rb
Normal file
99
db/migrate/20250612150749_create_doorkeeper_tables.rb
Normal file
|
@ -0,0 +1,99 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateDoorkeeperTables < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :oauth_applications do |t|
|
||||
t.string :name, null: false
|
||||
t.string :uid, null: false
|
||||
# Remove `null: false` or use conditional constraint if you are planning to use public clients.
|
||||
t.string :secret, null: false
|
||||
|
||||
# Remove `null: false` if you are planning to use grant flows
|
||||
# that doesn't require redirect URI to be used during authorization
|
||||
# like Client Credentials flow or Resource Owner Password.
|
||||
t.text :redirect_uri, null: false
|
||||
t.string :scopes, null: false, default: ''
|
||||
t.boolean :confidential, null: false, default: true
|
||||
t.timestamps null: false
|
||||
end
|
||||
|
||||
add_index :oauth_applications, :uid, unique: true
|
||||
|
||||
create_table :oauth_access_grants do |t|
|
||||
t.references :resource_owner, null: false
|
||||
t.references :application, null: false
|
||||
t.string :token, null: false
|
||||
t.integer :expires_in, null: false
|
||||
t.text :redirect_uri, null: false
|
||||
t.string :scopes, null: false, default: ''
|
||||
t.datetime :created_at, null: false
|
||||
t.datetime :revoked_at
|
||||
end
|
||||
|
||||
add_index :oauth_access_grants, :token, unique: true
|
||||
add_foreign_key(
|
||||
:oauth_access_grants,
|
||||
:oauth_applications,
|
||||
column: :application_id
|
||||
)
|
||||
|
||||
create_table :oauth_access_tokens do |t|
|
||||
t.references :resource_owner, index: true
|
||||
|
||||
# Remove `null: false` if you are planning to use Password
|
||||
# Credentials Grant flow that doesn't require an application.
|
||||
t.references :application, null: false
|
||||
|
||||
# If you use a custom token generator you may need to change this column
|
||||
# from string to text, so that it accepts tokens larger than 255
|
||||
# characters. More info on custom token generators in:
|
||||
# https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator
|
||||
#
|
||||
# t.text :token, null: false
|
||||
t.string :token, null: false
|
||||
|
||||
t.string :refresh_token
|
||||
t.integer :expires_in
|
||||
t.string :scopes
|
||||
t.datetime :created_at, null: false
|
||||
t.datetime :revoked_at
|
||||
|
||||
# The authorization server MAY issue a new refresh token, in which case
|
||||
# *the client MUST discard the old refresh token* and replace it with the
|
||||
# new refresh token. The authorization server MAY revoke the old
|
||||
# refresh token after issuing a new refresh token to the client.
|
||||
# @see https://datatracker.ietf.org/doc/html/rfc6749#section-6
|
||||
#
|
||||
# Doorkeeper implementation: if there is a `previous_refresh_token` column,
|
||||
# refresh tokens will be revoked after a related access token is used.
|
||||
# If there is no `previous_refresh_token` column, previous tokens are
|
||||
# revoked as soon as a new access token is created.
|
||||
#
|
||||
# Comment out this line if you want refresh tokens to be instantly
|
||||
# revoked after use.
|
||||
t.string :previous_refresh_token, null: false, default: ""
|
||||
end
|
||||
|
||||
add_index :oauth_access_tokens, :token, unique: true
|
||||
|
||||
# See https://github.com/doorkeeper-gem/doorkeeper/issues/1592
|
||||
if ActiveRecord::Base.connection.adapter_name == "SQLServer"
|
||||
execute <<~SQL.squish
|
||||
CREATE UNIQUE NONCLUSTERED INDEX index_oauth_access_tokens_on_refresh_token ON oauth_access_tokens(refresh_token)
|
||||
WHERE refresh_token IS NOT NULL
|
||||
SQL
|
||||
else
|
||||
add_index :oauth_access_tokens, :refresh_token, unique: true
|
||||
end
|
||||
|
||||
add_foreign_key(
|
||||
:oauth_access_tokens,
|
||||
:oauth_applications,
|
||||
column: :application_id
|
||||
)
|
||||
|
||||
# Uncomment below to ensure a valid reference to the resource owner's table
|
||||
# add_foreign_key :oauth_access_grants, <model>, column: :resource_owner_id
|
||||
# add_foreign_key :oauth_access_tokens, <model>, column: :resource_owner_id
|
||||
end
|
||||
end
|
|
@ -0,0 +1,9 @@
|
|||
class FixDoorkeeperResourceOwnerIdForUuid < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
change_column :oauth_access_tokens, :resource_owner_id, :string
|
||||
end
|
||||
|
||||
def down
|
||||
change_column :oauth_access_tokens, :resource_owner_id, :integer
|
||||
end
|
||||
end
|
17
db/migrate/20250613002027_create_api_keys.rb
Normal file
17
db/migrate/20250613002027_create_api_keys.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
class CreateApiKeys < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :api_keys, id: :uuid do |t|
|
||||
t.string :key
|
||||
t.string :name
|
||||
t.references :user, null: false, foreign_key: true, type: :uuid
|
||||
t.json :scopes
|
||||
t.datetime :last_used_at
|
||||
t.datetime :expires_at
|
||||
t.datetime :revoked_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
add_index :api_keys, :key
|
||||
add_index :api_keys, :revoked_at
|
||||
end
|
||||
end
|
6
db/migrate/20250613100842_add_display_key_to_api_keys.rb
Normal file
6
db/migrate/20250613100842_add_display_key_to_api_keys.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
class AddDisplayKeyToApiKeys < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :api_keys, :display_key, :string, null: false
|
||||
add_index :api_keys, :display_key, unique: true
|
||||
end
|
||||
end
|
5
db/migrate/20250613101036_remove_key_from_api_keys.rb
Normal file
5
db/migrate/20250613101036_remove_key_from_api_keys.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class RemoveKeyFromApiKeys < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
remove_column :api_keys, :key, :string
|
||||
end
|
||||
end
|
|
@ -0,0 +1,5 @@
|
|||
class RemoveKeyIndexFromApiKeys < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
remove_index :api_keys, :key if index_exists?(:api_keys, :key)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,9 @@
|
|||
class FixDoorkeeperAccessGrantsResourceOwnerIdForUuid < ActiveRecord::Migration[7.2]
|
||||
def up
|
||||
change_column :oauth_access_grants, :resource_owner_id, :string
|
||||
end
|
||||
|
||||
def down
|
||||
change_column :oauth_access_grants, :resource_owner_id, :bigint
|
||||
end
|
||||
end
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue