mirror of
https://github.com/we-promise/sure.git
synced 2026-04-20 12:34:12 +00:00
Reorganize Settings sections + add LLM model/prompt configs (#116)
* Reshuffle/organize settings UI * Settings: AI prompt display/minor touch-ups * API key settings tests * Moved import/export together * Collapsible LLM prompt DIVs * Add export tests
This commit is contained in:
@@ -2,7 +2,7 @@ class FamilyExportsController < ApplicationController
|
|||||||
include StreamExtensions
|
include StreamExtensions
|
||||||
|
|
||||||
before_action :require_admin
|
before_action :require_admin
|
||||||
before_action :set_export, only: [ :download ]
|
before_action :set_export, only: [ :download, :destroy ]
|
||||||
|
|
||||||
def new
|
def new
|
||||||
# Modal view for initiating export
|
# Modal view for initiating export
|
||||||
@@ -13,9 +13,9 @@ class FamilyExportsController < ApplicationController
|
|||||||
FamilyDataExportJob.perform_later(@export)
|
FamilyDataExportJob.perform_later(@export)
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html { redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." }
|
format.html { redirect_to imports_path, notice: "Export started. You'll be able to download it shortly." }
|
||||||
format.turbo_stream {
|
format.turbo_stream {
|
||||||
stream_redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly."
|
stream_redirect_to imports_path, notice: "Export started. You'll be able to download it shortly."
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -29,10 +29,15 @@ class FamilyExportsController < ApplicationController
|
|||||||
if @export.downloadable?
|
if @export.downloadable?
|
||||||
redirect_to @export.export_file, allow_other_host: true
|
redirect_to @export.export_file, allow_other_host: true
|
||||||
else
|
else
|
||||||
redirect_to settings_profile_path, alert: "Export not ready for download"
|
redirect_to imports_path, alert: "Export not ready for download"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@export.destroy
|
||||||
|
redirect_to imports_path, notice: "Export deleted successfully"
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_export
|
def set_export
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class ImportsController < ApplicationController
|
class ImportsController < ApplicationController
|
||||||
|
include SettingsHelper
|
||||||
|
|
||||||
before_action :set_import, only: %i[show publish destroy revert apply_template]
|
before_action :set_import, only: %i[show publish destroy revert apply_template]
|
||||||
|
|
||||||
def publish
|
def publish
|
||||||
@@ -11,7 +13,11 @@ class ImportsController < ApplicationController
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
@imports = Current.family.imports
|
@imports = Current.family.imports
|
||||||
|
@exports = Current.user.admin? ? Current.family.family_exports.ordered.limit(10) : nil
|
||||||
|
@breadcrumbs = [
|
||||||
|
[ "Home", root_path ],
|
||||||
|
[ "Import/Export", imports_path ]
|
||||||
|
]
|
||||||
render layout: "settings"
|
render layout: "settings"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
12
app/controllers/settings/ai_prompts_controller.rb
Normal file
12
app/controllers/settings/ai_prompts_controller.rb
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
class Settings::AiPromptsController < ApplicationController
|
||||||
|
layout "settings"
|
||||||
|
|
||||||
|
def show
|
||||||
|
@breadcrumbs = [
|
||||||
|
[ "Home", root_path ],
|
||||||
|
[ "AI Prompts", nil ]
|
||||||
|
]
|
||||||
|
@family = Current.family
|
||||||
|
@assistant_config = Assistant.config_for(OpenStruct.new(user: Current.user))
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -8,7 +8,7 @@ class Settings::ApiKeysController < ApplicationController
|
|||||||
def show
|
def show
|
||||||
@breadcrumbs = [
|
@breadcrumbs = [
|
||||||
[ "Home", root_path ],
|
[ "Home", root_path ],
|
||||||
[ "API Keys", nil ]
|
[ "API Key", nil ]
|
||||||
]
|
]
|
||||||
@current_api_key = @api_key
|
@current_api_key = @api_key
|
||||||
end
|
end
|
||||||
|
|||||||
18
app/controllers/settings/guides_controller.rb
Normal file
18
app/controllers/settings/guides_controller.rb
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
class Settings::GuidesController < ApplicationController
|
||||||
|
layout "settings"
|
||||||
|
|
||||||
|
def show
|
||||||
|
@breadcrumbs = [
|
||||||
|
[ "Home", root_path ],
|
||||||
|
[ "Guides", nil ]
|
||||||
|
]
|
||||||
|
markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML,
|
||||||
|
autolink: true,
|
||||||
|
tables: true,
|
||||||
|
fenced_code_blocks: true,
|
||||||
|
strikethrough: true,
|
||||||
|
superscript: true
|
||||||
|
)
|
||||||
|
@guide_content = markdown.render(File.read(Rails.root.join("docs/onboarding/guide.md")))
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,6 +5,10 @@ class Settings::ProfilesController < ApplicationController
|
|||||||
@user = Current.user
|
@user = Current.user
|
||||||
@users = Current.family.users.order(:created_at)
|
@users = Current.family.users.order(:created_at)
|
||||||
@pending_invitations = Current.family.invitations.pending
|
@pending_invitations = Current.family.invitations.pending
|
||||||
|
@breadcrumbs = [
|
||||||
|
[ "Home", root_path ],
|
||||||
|
[ "Profile Info", nil ]
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
module SettingsHelper
|
module SettingsHelper
|
||||||
SETTINGS_ORDER = [
|
SETTINGS_ORDER = [
|
||||||
{ name: "Account", path: :settings_profile_path },
|
# General section
|
||||||
{ name: "Preferences", path: :settings_preferences_path },
|
|
||||||
{ name: "Security", path: :settings_security_path },
|
|
||||||
{ name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted? },
|
|
||||||
{ name: "API Key", path: :settings_api_key_path },
|
|
||||||
{ name: "Billing", path: :settings_billing_path, condition: :not_self_hosted? },
|
|
||||||
{ name: "Accounts", path: :accounts_path },
|
{ name: "Accounts", path: :accounts_path },
|
||||||
{ name: "Imports", path: :imports_path },
|
{ name: "Bank Sync", path: :settings_bank_sync_path },
|
||||||
{ name: "Tags", path: :tags_path },
|
{ name: "Preferences", path: :settings_preferences_path },
|
||||||
|
{ name: "Profile Info", path: :settings_profile_path },
|
||||||
|
{ name: "Security", path: :settings_security_path },
|
||||||
|
{ name: "Billing", path: :settings_billing_path, condition: :not_self_hosted? },
|
||||||
|
# Transactions section
|
||||||
{ name: "Categories", path: :categories_path },
|
{ name: "Categories", path: :categories_path },
|
||||||
|
{ name: "Tags", path: :tags_path },
|
||||||
{ name: "Rules", path: :rules_path },
|
{ name: "Rules", path: :rules_path },
|
||||||
{ name: "Merchants", path: :family_merchants_path },
|
{ name: "Merchants", path: :family_merchants_path },
|
||||||
|
# Advanced section
|
||||||
|
{ name: "AI Prompts", path: :settings_ai_prompts_path },
|
||||||
|
{ name: "API Key", path: :settings_api_key_path },
|
||||||
|
{ name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted? },
|
||||||
|
{ name: "Imports", path: :imports_path },
|
||||||
|
{ name: "SimpleFin", path: :simplefin_items_path },
|
||||||
|
# More section
|
||||||
|
{ name: "Guides", path: :settings_guides_path },
|
||||||
{ name: "What's new", path: :changelog_path },
|
{ name: "What's new", path: :changelog_path },
|
||||||
{ name: "Feedback", path: :feedback_path }
|
{ name: "Feedback", path: :feedback_path }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class FamilyExport < ApplicationRecord
|
class FamilyExport < ApplicationRecord
|
||||||
belongs_to :family
|
belongs_to :family
|
||||||
|
|
||||||
has_one_attached :export_file
|
has_one_attached :export_file, dependent: :purge_later
|
||||||
|
|
||||||
enum :status, {
|
enum :status, {
|
||||||
pending: "pending",
|
pending: "pending",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class Provider::Openai < Provider
|
|||||||
MODELS.include?(model)
|
MODELS.include?(model)
|
||||||
end
|
end
|
||||||
|
|
||||||
def auto_categorize(transactions: [], user_categories: [], model: "gpt-4.1-mini")
|
def auto_categorize(transactions: [], user_categories: [], model: "")
|
||||||
with_provider_response do
|
with_provider_response do
|
||||||
raise Error, "Too many transactions to auto-categorize. Max is 25 per request." if transactions.size > 25
|
raise Error, "Too many transactions to auto-categorize. Max is 25 per request." if transactions.size > 25
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ class Provider::Openai < Provider
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def auto_detect_merchants(transactions: [], user_merchants: [], model: "gpt-4.1-mini")
|
def auto_detect_merchants(transactions: [], user_merchants: [], model: "")
|
||||||
with_provider_response do
|
with_provider_response do
|
||||||
raise Error, "Too many transactions to auto-detect merchants. Max is 25 per request." if transactions.size > 25
|
raise Error, "Too many transactions to auto-detect merchants. Max is 25 per request." if transactions.size > 25
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class Provider::Openai::AutoCategorizer
|
class Provider::Openai::AutoCategorizer
|
||||||
|
DEFAULT_MODEL = "gpt-4.1-mini"
|
||||||
|
|
||||||
def initialize(client, model: "", transactions: [], user_categories: [])
|
def initialize(client, model: "", transactions: [], user_categories: [])
|
||||||
@client = client
|
@client = client
|
||||||
@model = model
|
@model = model
|
||||||
@@ -8,7 +10,7 @@ class Provider::Openai::AutoCategorizer
|
|||||||
|
|
||||||
def auto_categorize
|
def auto_categorize
|
||||||
response = client.responses.create(parameters: {
|
response = client.responses.create(parameters: {
|
||||||
model: model,
|
model: model.presence || DEFAULT_MODEL,
|
||||||
input: [ { role: "developer", content: developer_message } ],
|
input: [ { role: "developer", content: developer_message } ],
|
||||||
text: {
|
text: {
|
||||||
format: {
|
format: {
|
||||||
@@ -26,6 +28,27 @@ class Provider::Openai::AutoCategorizer
|
|||||||
build_response(extract_categorizations(response))
|
build_response(extract_categorizations(response))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def instructions
|
||||||
|
<<~INSTRUCTIONS.strip_heredoc
|
||||||
|
You are an assistant to a consumer personal finance app. You will be provided a list
|
||||||
|
of the user's transactions and a list of the user's categories. Your job is to auto-categorize
|
||||||
|
each transaction.
|
||||||
|
|
||||||
|
Closely follow ALL the rules below while auto-categorizing:
|
||||||
|
|
||||||
|
- Return 1 result per transaction
|
||||||
|
- Correlate each transaction by ID (transaction_id)
|
||||||
|
- Attempt to match the most specific category possible (i.e. subcategory over parent category)
|
||||||
|
- Category and transaction classifications should match (i.e. if transaction is an "expense", the category must have classification of "expense")
|
||||||
|
- If you don't know the category, return "null"
|
||||||
|
- You should always favor "null" over false positives
|
||||||
|
- Be slightly pessimistic. Only match a category if you're 60%+ confident it is the correct one.
|
||||||
|
- Each transaction has varying metadata that can be used to determine the category
|
||||||
|
- Note: "hint" comes from 3rd party aggregators and typically represents a category name that
|
||||||
|
may or may not match any of the user-supplied categories
|
||||||
|
INSTRUCTIONS
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
attr_reader :client, :model, :transactions, :user_categories
|
attr_reader :client, :model, :transactions, :user_categories
|
||||||
|
|
||||||
@@ -97,25 +120,4 @@ class Provider::Openai::AutoCategorizer
|
|||||||
```
|
```
|
||||||
MESSAGE
|
MESSAGE
|
||||||
end
|
end
|
||||||
|
|
||||||
def instructions
|
|
||||||
<<~INSTRUCTIONS.strip_heredoc
|
|
||||||
You are an assistant to a consumer personal finance app. You will be provided a list
|
|
||||||
of the user's transactions and a list of the user's categories. Your job is to auto-categorize
|
|
||||||
each transaction.
|
|
||||||
|
|
||||||
Closely follow ALL the rules below while auto-categorizing:
|
|
||||||
|
|
||||||
- Return 1 result per transaction
|
|
||||||
- Correlate each transaction by ID (transaction_id)
|
|
||||||
- Attempt to match the most specific category possible (i.e. subcategory over parent category)
|
|
||||||
- Category and transaction classifications should match (i.e. if transaction is an "expense", the category must have classification of "expense")
|
|
||||||
- If you don't know the category, return "null"
|
|
||||||
- You should always favor "null" over false positives
|
|
||||||
- Be slightly pessimistic. Only match a category if you're 60%+ confident it is the correct one.
|
|
||||||
- Each transaction has varying metadata that can be used to determine the category
|
|
||||||
- Note: "hint" comes from 3rd party aggregators and typically represents a category name that
|
|
||||||
may or may not match any of the user-supplied categories
|
|
||||||
INSTRUCTIONS
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class Provider::Openai::AutoMerchantDetector
|
class Provider::Openai::AutoMerchantDetector
|
||||||
|
DEFAULT_MODEL = "gpt-4.1-mini"
|
||||||
|
|
||||||
def initialize(client, model: "", transactions:, user_merchants:)
|
def initialize(client, model: "", transactions:, user_merchants:)
|
||||||
@client = client
|
@client = client
|
||||||
@model = model
|
@model = model
|
||||||
@@ -8,7 +10,7 @@ class Provider::Openai::AutoMerchantDetector
|
|||||||
|
|
||||||
def auto_detect_merchants
|
def auto_detect_merchants
|
||||||
response = client.responses.create(parameters: {
|
response = client.responses.create(parameters: {
|
||||||
model: model,
|
model: model.presence || DEFAULT_MODEL,
|
||||||
input: [ { role: "developer", content: developer_message } ],
|
input: [ { role: "developer", content: developer_message } ],
|
||||||
text: {
|
text: {
|
||||||
format: {
|
format: {
|
||||||
@@ -26,6 +28,47 @@ class Provider::Openai::AutoMerchantDetector
|
|||||||
build_response(extract_categorizations(response))
|
build_response(extract_categorizations(response))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def instructions
|
||||||
|
<<~INSTRUCTIONS.strip_heredoc
|
||||||
|
You are an assistant to a consumer personal finance app.
|
||||||
|
|
||||||
|
Closely follow ALL the rules below while auto-detecting business names and website URLs:
|
||||||
|
|
||||||
|
- Return 1 result per transaction
|
||||||
|
- Correlate each transaction by ID (transaction_id)
|
||||||
|
- Do not include the subdomain in the business_url (i.e. "amazon.com" not "www.amazon.com")
|
||||||
|
- User merchants are considered "manual" user-generated merchants and should only be used in 100% clear cases
|
||||||
|
- Be slightly pessimistic. We favor returning "null" over returning a false positive.
|
||||||
|
- NEVER return a name or URL for generic transaction names (e.g. "Paycheck", "Laundromat", "Grocery store", "Local diner")
|
||||||
|
|
||||||
|
Determining a value:
|
||||||
|
|
||||||
|
- First attempt to determine the name + URL from your knowledge of global businesses
|
||||||
|
- If no certain match, attempt to match one of the user-provided merchants
|
||||||
|
- If no match, return "null"
|
||||||
|
|
||||||
|
Example 1 (known business):
|
||||||
|
|
||||||
|
```
|
||||||
|
Transaction name: "Some Amazon purchases"
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- business_name: "Amazon"
|
||||||
|
- business_url: "amazon.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
Example 2 (generic business):
|
||||||
|
|
||||||
|
```
|
||||||
|
Transaction name: "local diner"
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- business_name: null
|
||||||
|
- business_url: null
|
||||||
|
```
|
||||||
|
INSTRUCTIONS
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
attr_reader :client, :model, :transactions, :user_merchants
|
attr_reader :client, :model, :transactions, :user_merchants
|
||||||
|
|
||||||
@@ -103,45 +146,4 @@ class Provider::Openai::AutoMerchantDetector
|
|||||||
Return "null" if you are not 80%+ confident in your answer.
|
Return "null" if you are not 80%+ confident in your answer.
|
||||||
MESSAGE
|
MESSAGE
|
||||||
end
|
end
|
||||||
|
|
||||||
def instructions
|
|
||||||
<<~INSTRUCTIONS.strip_heredoc
|
|
||||||
You are an assistant to a consumer personal finance app.
|
|
||||||
|
|
||||||
Closely follow ALL the rules below while auto-detecting business names and website URLs:
|
|
||||||
|
|
||||||
- Return 1 result per transaction
|
|
||||||
- Correlate each transaction by ID (transaction_id)
|
|
||||||
- Do not include the subdomain in the business_url (i.e. "amazon.com" not "www.amazon.com")
|
|
||||||
- User merchants are considered "manual" user-generated merchants and should only be used in 100% clear cases
|
|
||||||
- Be slightly pessimistic. We favor returning "null" over returning a false positive.
|
|
||||||
- NEVER return a name or URL for generic transaction names (e.g. "Paycheck", "Laundromat", "Grocery store", "Local diner")
|
|
||||||
|
|
||||||
Determining a value:
|
|
||||||
|
|
||||||
- First attempt to determine the name + URL from your knowledge of global businesses
|
|
||||||
- If no certain match, attempt to match one of the user-provided merchants
|
|
||||||
- If no match, return "null"
|
|
||||||
|
|
||||||
Example 1 (known business):
|
|
||||||
|
|
||||||
```
|
|
||||||
Transaction name: "Some Amazon purchases"
|
|
||||||
|
|
||||||
Result:
|
|
||||||
- business_name: "Amazon"
|
|
||||||
- business_url: "amazon.com"
|
|
||||||
```
|
|
||||||
|
|
||||||
Example 2 (generic business):
|
|
||||||
|
|
||||||
```
|
|
||||||
Transaction name: "local diner"
|
|
||||||
|
|
||||||
Result:
|
|
||||||
- business_name: null
|
|
||||||
- business_url: null
|
|
||||||
```
|
|
||||||
INSTRUCTIONS
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,13 +3,29 @@
|
|||||||
turbo_refresh_url: family_exports_path,
|
turbo_refresh_url: family_exports_path,
|
||||||
turbo_refresh_interval: 3000
|
turbo_refresh_interval: 3000
|
||||||
} : {} do %>
|
} : {} do %>
|
||||||
<div class="mt-4 space-y-3 max-h-96 overflow-y-auto">
|
<div class="space-y-0 max-h-96 overflow-y-auto">
|
||||||
<% if exports.any? %>
|
<% if exports.any? %>
|
||||||
<% exports.each do |export| %>
|
<% exports.each do |export| %>
|
||||||
<div class="flex items-center justify-between bg-container p-4 rounded-lg border border-primary">
|
<div class="flex items-center justify-between mx-4 py-4">
|
||||||
<div>
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<p class="text-sm font-medium text-primary">Export from <%= export.created_at.strftime("%B %d, %Y at %I:%M %p") %></p>
|
<div>
|
||||||
<p class="text-xs text-secondary"><%= export.filename %></p>
|
<p class="text-sm font-medium text-primary">Export from <%= export.created_at.strftime("%B %d, %Y at %I:%M %p") %></p>
|
||||||
|
<p class="text-xs text-secondary"><%= export.filename %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if export.processing? || export.pending? %>
|
||||||
|
<span class="px-1 py text-xs rounded-full bg-gray-500/5 text-secondary border border-alpha-black-50">
|
||||||
|
<%= t(".in_progress") %>
|
||||||
|
</span>
|
||||||
|
<% elsif export.completed? %>
|
||||||
|
<span class="px-1 py text-xs rounded-full bg-green-500/5 text-green-500 border border-alpha-black-50">
|
||||||
|
<%= t(".complete") %>
|
||||||
|
</span>
|
||||||
|
<% elsif export.failed? %>
|
||||||
|
<span class="px-1 py text-xs rounded-full bg-red-500/5 text-red-500 border border-alpha-black-50">
|
||||||
|
<%= t(".failed") %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if export.processing? || export.pending? %>
|
<% if export.processing? || export.pending? %>
|
||||||
@@ -18,22 +34,44 @@
|
|||||||
<span class="text-sm">Exporting...</span>
|
<span class="text-sm">Exporting...</span>
|
||||||
</div>
|
</div>
|
||||||
<% elsif export.completed? %>
|
<% elsif export.completed? %>
|
||||||
<%= link_to download_family_export_path(export),
|
<div class="flex items-center gap-2">
|
||||||
class: "flex items-center gap-2 text-primary hover:text-primary-hover",
|
<%= button_to family_export_path(export),
|
||||||
data: { turbo_frame: "_top" } do %>
|
method: :delete,
|
||||||
<%= icon "download", class: "w-5 h-5" %>
|
class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
|
||||||
<span class="text-sm font-medium">Download</span>
|
data: {
|
||||||
<% end %>
|
turbo_confirm: "Are you sure you want to delete this export? This action cannot be undone.",
|
||||||
|
turbo_frame: "_top"
|
||||||
|
} do %>
|
||||||
|
<%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to download_family_export_path(export),
|
||||||
|
class: "flex items-center gap-2 text-primary hover:text-primary-hover",
|
||||||
|
data: { turbo_frame: "_top" } do %>
|
||||||
|
<%= icon "download", class: "w-5 h-5" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
<% elsif export.failed? %>
|
<% elsif export.failed? %>
|
||||||
<div class="flex items-center gap-2 text-destructive">
|
<div class="flex items-center gap-2">
|
||||||
<%= icon "alert-circle", class: "w-4 h-4" %>
|
<div class="flex items-center gap-2 text-destructive">
|
||||||
<span class="text-sm">Failed</span>
|
<%= icon "alert-circle", class: "w-4 h-4" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= button_to family_export_path(export),
|
||||||
|
method: :delete,
|
||||||
|
class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
|
||||||
|
data: {
|
||||||
|
turbo_confirm: "Are you sure you want to delete this failed export?",
|
||||||
|
turbo_frame: "_top"
|
||||||
|
} do %>
|
||||||
|
<%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="text-sm text-secondary text-center py-4">No exports yet</p>
|
<p class="text-sm text-primary text-center py-4 mb-1 font-medium">No exports yet.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
<div class="flex justify-center items-center py-20">
|
<div class="flex justify-center items-center py-20">
|
||||||
<div class="text-center flex flex-col items-center max-w-[300px] gap-4">
|
<div class="text-center flex flex-col items-center max-w-[300px] gap-4">
|
||||||
<p class="text-primary mb-1 font-medium text-sm"><%= t(".message") %></p>
|
<p class="text-primary mb-1 font-medium text-sm"><%= t(".message") %></p>
|
||||||
|
|
||||||
<%= render DS::Link.new(
|
|
||||||
text: t(".new"),
|
|
||||||
variant: "primary",
|
|
||||||
href: new_import_path,
|
|
||||||
icon: "plus",
|
|
||||||
frame: :modal
|
|
||||||
) %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,30 +36,36 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= render DS::Menu.new do |menu| %>
|
<div class="flex items-center gap-2">
|
||||||
<% menu.with_item(variant: "link", text: t(".view"), href: import_path(import), icon: "eye") %>
|
|
||||||
|
|
||||||
<% if import.complete? || import.revert_failed? %>
|
<% if import.complete? || import.revert_failed? %>
|
||||||
<% menu.with_item(
|
<%= button_to revert_import_path(import),
|
||||||
variant: "button",
|
method: :put,
|
||||||
text: t(".revert"),
|
class: "flex items-center gap-2 text-orange-500 hover:text-orange-600",
|
||||||
href: revert_import_path(import),
|
data: {
|
||||||
icon: "rotate-ccw",
|
turbo_confirm: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time."
|
||||||
method: :put,
|
} do %>
|
||||||
confirm: CustomConfirm.new(
|
<%= icon "rotate-ccw", class: "w-5 h-5 text-destructive" %>
|
||||||
title: "Revert import?",
|
<% end %>
|
||||||
body: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time.",
|
|
||||||
btn_text: "Revert"
|
|
||||||
)) %>
|
|
||||||
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<% menu.with_item(
|
<%= button_to import_path(import),
|
||||||
variant: "button",
|
method: :delete,
|
||||||
text: t(".delete"),
|
class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
|
||||||
href: import_path(import),
|
data: {
|
||||||
icon: "trash-2",
|
turbo_confirm: CustomConfirm.for_resource_deletion("import")
|
||||||
method: :delete,
|
} do %>
|
||||||
confirm: CustomConfirm.for_resource_deletion("import")) %>
|
<%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
<%= link_to import_path(import),
|
||||||
|
class: "flex items-center gap-2 text-primary hover:text-primary-hover" do %>
|
||||||
|
<%= icon "eye", class: "w-5 h-5" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,39 @@
|
|||||||
<div class="flex items-center justify-between">
|
<%= settings_section title: t(".imports") do %>
|
||||||
<h1 class="text-xl font-medium text-primary"><%= t(".title") %></h1>
|
<div class="space-y-4">
|
||||||
|
<% if @imports.empty? %>
|
||||||
<%= render DS::Link.new(
|
<%= render partial: "imports/empty" %>
|
||||||
text: "New import",
|
<% else %>
|
||||||
href: new_import_path,
|
<div class="bg-container rounded-lg shadow-border-xs">
|
||||||
icon: "plus",
|
<%= render partial: "imports/import", collection: @imports.ordered %>
|
||||||
variant: "primary",
|
|
||||||
frame: :modal
|
|
||||||
) %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-container shadow-border-xs rounded-xl p-4">
|
|
||||||
<% if @imports.empty? %>
|
|
||||||
<%= render partial: "imports/empty" %>
|
|
||||||
<% else %>
|
|
||||||
<div class="rounded-xl bg-container-inset p-1">
|
|
||||||
<h2 class="uppercase px-4 py-2 text-secondary text-xs"><%= t(".imports") %> · <%= @imports.size %></h2>
|
|
||||||
|
|
||||||
<div class="border border-alpha-black-100 rounded-lg bg-container shadow-xs">
|
|
||||||
<%= render partial: "imports/import", collection: @imports.ordered, spacer_template: "shared/ruler" %>
|
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to new_import_path,
|
||||||
|
class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center",
|
||||||
|
data: { turbo_frame: :modal } do %>
|
||||||
|
<%= icon("plus") %>
|
||||||
|
<%= t(".new") %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if Current.user.admin? %>
|
||||||
|
<%= settings_section title: t(".exports") do %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-container rounded-lg shadow-border-xs">
|
||||||
|
<%= turbo_frame_tag "family_exports", src: family_exports_path, loading: :lazy do %>
|
||||||
|
<div class="mt-4 text-center text-secondary py-8">
|
||||||
|
<div class="animate-spin inline-block h-4 w-4 border-2 border-secondary border-t-transparent rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= link_to new_family_export_path,
|
||||||
|
class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center",
|
||||||
|
data: { turbo_frame: :modal } do %>
|
||||||
|
<%= icon("plus") %>
|
||||||
|
<%= t(".new_export") %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
<% end %>
|
||||||
|
|||||||
@@ -3,30 +3,37 @@ nav_sections = [
|
|||||||
{
|
{
|
||||||
header: t(".general_section_title"),
|
header: t(".general_section_title"),
|
||||||
items: [
|
items: [
|
||||||
{ 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: t(".api_keys_label"), path: settings_api_key_path, icon: "key" },
|
|
||||||
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
|
|
||||||
{ label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? },
|
|
||||||
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },
|
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },
|
||||||
{ label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" },
|
{ label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" },
|
||||||
{ label: "SimpleFin", path: simplefin_items_path, icon: "building-2" },
|
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
|
||||||
{ label: t(".imports_label"), path: imports_path, icon: "download" }
|
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
|
||||||
|
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
|
||||||
|
{ label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t(".transactions_section_title"),
|
header: t(".transactions_section_title"),
|
||||||
items: [
|
items: [
|
||||||
{ label: t(".tags_label"), path: tags_path, icon: "tags" },
|
|
||||||
{ label: t(".categories_label"), path: categories_path, icon: "shapes" },
|
{ label: t(".categories_label"), path: categories_path, icon: "shapes" },
|
||||||
|
{ label: t(".tags_label"), path: tags_path, icon: "tags" },
|
||||||
{ label: t(".rules_label"), path: rules_path, icon: "git-branch" },
|
{ label: t(".rules_label"), path: rules_path, icon: "git-branch" },
|
||||||
{ label: t(".merchants_label"), path: family_merchants_path, icon: "store" }
|
{ label: t(".merchants_label"), path: family_merchants_path, icon: "store" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: t(".advanced_section_title"),
|
||||||
|
items: [
|
||||||
|
{ label: t(".ai_prompts_label"), path: settings_ai_prompts_path, icon: "bot" },
|
||||||
|
{ label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" },
|
||||||
|
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
|
||||||
|
{ label: t(".imports_label"), path: imports_path, icon: "download" },
|
||||||
|
{ label: "SimpleFin", path: simplefin_items_path, icon: "building-2" }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: t(".other_section_title"),
|
header: t(".other_section_title"),
|
||||||
items: [
|
items: [
|
||||||
|
{ label: t(".guides_label"), path: settings_guides_path, icon: "book-open" },
|
||||||
{ label: t(".whats_new_label"), path: changelog_path, icon: "box" },
|
{ label: t(".whats_new_label"), path: changelog_path, icon: "box" },
|
||||||
{ label: t(".feedback_label"), path: feedback_path, icon: "megaphone" }
|
{ label: t(".feedback_label"), path: feedback_path, icon: "megaphone" }
|
||||||
]
|
]
|
||||||
|
|||||||
95
app/views/settings/ai_prompts/show.html.erb
Normal file
95
app/views/settings/ai_prompts/show.html.erb
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<%= content_for :page_title, t(".page_title") %>
|
||||||
|
|
||||||
|
<div class="bg-container rounded-xl shadow-border-xs p-4">
|
||||||
|
<div class="rounded-xl bg-container-inset space-y-1 p-1">
|
||||||
|
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary">
|
||||||
|
<p><%= t(".openai_label") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-container rounded-lg shadow-border-xs">
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
<!-- Main System Prompt Section -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-9 h-9 rounded-full bg-gray-25 flex justify-center items-center">
|
||||||
|
<%= icon "message-circle" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-primary"><%= t(".main_system_prompt.title") %></p>
|
||||||
|
<p class="text-xs text-secondary"><%= t(".main_system_prompt.subtitle") %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pl-12 space-y-2">
|
||||||
|
<details class="group">
|
||||||
|
<summary class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<span class="text-xs font-medium text-primary uppercase"><%= t(".prompt_instructions") %></span>
|
||||||
|
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||||
|
</summary>
|
||||||
|
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::MODELS.join(", ") %>]</pre>
|
||||||
|
<div class="mt-2 px-3 py-2 bg-surface-default border border-primary rounded-lg">
|
||||||
|
<pre class="whitespace-pre-wrap text-xs font-mono text-primary"><%= @assistant_config[:instructions] %></pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-primary"></div>
|
||||||
|
|
||||||
|
<!-- Auto-Categorization Section -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-9 h-9 rounded-full bg-gray-25 flex justify-center items-center">
|
||||||
|
<%= icon "brain" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-primary"><%= t(".transaction_categorizer.title") %></p>
|
||||||
|
<p class="text-xs text-secondary"><%= t(".transaction_categorizer.subtitle") %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pl-12 space-y-2">
|
||||||
|
<details class="group">
|
||||||
|
<summary class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<span class="text-xs font-medium text-primary uppercase"><%= t(".prompt_instructions") %></span>
|
||||||
|
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||||
|
</summary>
|
||||||
|
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::AutoCategorizer::DEFAULT_MODEL %>]</pre>
|
||||||
|
<div class="mt-2 px-3 py-2 bg-surface-default border border-primary rounded-lg">
|
||||||
|
<pre class="whitespace-pre-wrap text-xs font-mono text-primary"><%= @assistant_config[:auto_categorizer]&.instructions || Provider::Openai::AutoCategorizer.new(nil).instructions %></pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-primary"></div>
|
||||||
|
|
||||||
|
<!-- Merchant Detection Section -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-9 h-9 rounded-full bg-gray-25 flex justify-center items-center">
|
||||||
|
<%= icon "store" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-primary"><%= t(".merchant_detector.title") %></p>
|
||||||
|
<p class="text-xs text-secondary"><%= t(".merchant_detector.subtitle") %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pl-12 space-y-2">
|
||||||
|
<details class="group">
|
||||||
|
<summary class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<span class="text-xs font-medium text-primary uppercase"><%= t(".prompt_instructions") %></span>
|
||||||
|
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||||
|
</summary>
|
||||||
|
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::AutoMerchantDetector::DEFAULT_MODEL %>]</pre>
|
||||||
|
<div class="mt-2 px-3 py-2 bg-surface-default border border-primary rounded-lg">
|
||||||
|
<pre class="whitespace-pre-wrap text-xs font-mono text-primary"><%= @assistant_config[:auto_merchant]&.instructions || Provider::Openai::AutoMerchantDetector.new(nil, model: "", transactions: [], user_merchants: []).instructions %></pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<%= content_for :page_title, "Create New API Key" %>
|
<%= 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 %>
|
<%= settings_section title: nil, 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| %>
|
<%= styled_form_with model: @api_key, url: settings_api_key_path, class: "space-y-4" do |form| %>
|
||||||
<%= form.text_field :name,
|
<%= form.text_field :name,
|
||||||
placeholder: "e.g., My Budget App, Portfolio Tracker",
|
placeholder: "e.g., My Budget App, Portfolio Tracker",
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
) %>
|
) %>
|
||||||
|
|
||||||
<%= render DS::Button.new(
|
<%= render DS::Button.new(
|
||||||
text: "Create API Key",
|
text: "Save API Key",
|
||||||
variant: "primary",
|
variant: "primary",
|
||||||
type: "submit"
|
type: "submit"
|
||||||
) %>
|
) %>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<%= content_for :page_title, "API Key" %>
|
|
||||||
|
|
||||||
<% if @newly_created && @plain_key %>
|
<% if @newly_created && @plain_key %>
|
||||||
<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %>
|
<header class="flex items-center justify-between">
|
||||||
|
<h1 class="text-primary text-xl font-medium">API Key Created Successfully</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="bg-container rounded-xl shadow-border-xs p-4">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="p-3 shadow-border-xs bg-container rounded-lg">
|
<div class="p-3 shadow-border-xs bg-container rounded-lg">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
@@ -51,9 +53,18 @@
|
|||||||
) %>
|
) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
</div>
|
||||||
<% elsif @current_api_key %>
|
<% elsif @current_api_key %>
|
||||||
<%= settings_section title: "Your API Key", subtitle: "Manage your API key for programmatic access to your Maybe data." do %>
|
<header class="flex items-center justify-between">
|
||||||
|
<h1 class="text-primary text-xl font-medium">Your API Key</h1>
|
||||||
|
<%= render DS::Link.new(
|
||||||
|
text: "Create New Key",
|
||||||
|
href: new_settings_api_key_path(regenerate: true),
|
||||||
|
variant: "secondary"
|
||||||
|
) %>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="bg-container rounded-xl shadow-border-xs p-4">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center">
|
<div class="p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -122,13 +133,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-3 pt-4 border-t border-primary">
|
<div class="flex justify-end pt-4 border-t border-primary">
|
||||||
<%= render DS::Link.new(
|
|
||||||
text: "Create New Key",
|
|
||||||
href: new_settings_api_key_path(regenerate: true),
|
|
||||||
variant: "secondary"
|
|
||||||
) %>
|
|
||||||
|
|
||||||
<%= render DS::Button.new(
|
<%= render DS::Button.new(
|
||||||
text: "Revoke Key",
|
text: "Revoke Key",
|
||||||
href: settings_api_key_path,
|
href: settings_api_key_path,
|
||||||
@@ -140,9 +145,18 @@
|
|||||||
) %>
|
) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= settings_section title: "Create Your API Key", subtitle: "Get programmatic access to your Maybe data" do %>
|
<header class="flex items-center justify-between">
|
||||||
|
<h1 class="text-primary text-xl font-medium">API Key</h1>
|
||||||
|
<%= render DS::Link.new(
|
||||||
|
text: "Create API Key",
|
||||||
|
href: new_settings_api_key_path,
|
||||||
|
variant: "primary"
|
||||||
|
) %>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="bg-container rounded-xl shadow-border-xs p-4">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="p-3 shadow-border-xs bg-container rounded-lg">
|
<div class="p-3 shadow-border-xs bg-container rounded-lg">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
@@ -179,14 +193,6 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-start">
|
|
||||||
<%= render DS::Link.new(
|
|
||||||
text: "Create API Key",
|
|
||||||
href: new_settings_api_key_path,
|
|
||||||
variant: "primary"
|
|
||||||
) %>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
5
app/views/settings/guides/show.html.erb
Normal file
5
app/views/settings/guides/show.html.erb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<%= content_for :page_title, "Guides" %>
|
||||||
|
|
||||||
|
<div class="bg-container rounded-xl shadow-border-xs p-4 prose prose-sm max-w-none">
|
||||||
|
<%= @guide_content.html_safe %>
|
||||||
|
</div>
|
||||||
@@ -122,29 +122,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if Current.user.admin? %>
|
|
||||||
<%= settings_section title: "Data Import/Export" do %>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex gap-4 items-center">
|
|
||||||
<%= render DS::Link.new(
|
|
||||||
text: "Export data",
|
|
||||||
icon: "database",
|
|
||||||
href: new_family_export_path,
|
|
||||||
variant: "secondary",
|
|
||||||
full_width: true,
|
|
||||||
data: { turbo_frame: :modal }
|
|
||||||
) %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= turbo_frame_tag "family_exports", src: family_exports_path, loading: :lazy do %>
|
|
||||||
<div class="mt-4 text-center text-secondary">
|
|
||||||
<div class="animate-spin inline-block h-4 w-4 border-2 border-secondary border-t-transparent rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= settings_section title: t(".danger_zone_title") do %>
|
<%= settings_section title: t(".danger_zone_title") do %>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<% if Current.user.admin? %>
|
<% if Current.user.admin? %>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% menu.with_item(variant: "link", text: "Settings", icon: "settings", href: settings_profile_path(return_to: request.fullpath)) %>
|
<% menu.with_item(variant: "link", text: "Settings", icon: "settings", href: accounts_path(return_to: request.fullpath)) %>
|
||||||
<% menu.with_item(variant: "link", text: "Changelog", icon: "box", href: changelog_path) %>
|
<% menu.with_item(variant: "link", text: "Changelog", icon: "box", href: changelog_path) %>
|
||||||
|
|
||||||
<% if self_hosted? %>
|
<% if self_hosted? %>
|
||||||
|
|||||||
7
config/locales/views/family_exports/en.yml
Normal file
7
config/locales/views/family_exports/en.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
en:
|
||||||
|
family_exports:
|
||||||
|
list:
|
||||||
|
in_progress: In progress
|
||||||
|
complete: Complete
|
||||||
|
failed: Failed
|
||||||
7
config/locales/views/family_exports/nb.yml
Normal file
7
config/locales/views/family_exports/nb.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
nb:
|
||||||
|
family_exports:
|
||||||
|
list:
|
||||||
|
in_progress: Pågår
|
||||||
|
complete: Fullført
|
||||||
|
failed: Mislykket
|
||||||
7
config/locales/views/family_exports/tr.yml
Normal file
7
config/locales/views/family_exports/tr.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
tr:
|
||||||
|
family_exports:
|
||||||
|
list:
|
||||||
|
in_progress: Devam ediyor
|
||||||
|
complete: Tamamlandı
|
||||||
|
failed: Başarısız
|
||||||
@@ -58,7 +58,7 @@ en:
|
|||||||
imports:
|
imports:
|
||||||
empty:
|
empty:
|
||||||
message: No imports yet.
|
message: No imports yet.
|
||||||
new: New import
|
new: New Import
|
||||||
import:
|
import:
|
||||||
complete: Complete
|
complete: Complete
|
||||||
delete: Delete
|
delete: Delete
|
||||||
@@ -71,8 +71,11 @@ en:
|
|||||||
view: View
|
view: View
|
||||||
index:
|
index:
|
||||||
imports: Imports
|
imports: Imports
|
||||||
new: New import
|
new: New Import
|
||||||
title: Imports
|
title: Import/Export
|
||||||
|
exports: Exports
|
||||||
|
new_export: New Export
|
||||||
|
no_exports: No exports yet.
|
||||||
new:
|
new:
|
||||||
description: You can manually import various types of data via CSV or use one
|
description: You can manually import various types of data via CSV or use one
|
||||||
of our import templates like Mint.
|
of our import templates like Mint.
|
||||||
|
|||||||
@@ -1,90 +1,93 @@
|
|||||||
---
|
---
|
||||||
nb:
|
nb:
|
||||||
import:
|
import:
|
||||||
cleans:
|
cleans:
|
||||||
show:
|
show:
|
||||||
description: Rediger dataene dine i tabellen nedenfor. Røde celler er ugyldige.
|
description: Rediger dataene dine i tabellen nedenfor. Røde celler er ugyldige.
|
||||||
errors_notice: Du har feil i dataene dine. Hold musepekeren over feilen for å se
|
errors_notice: Du har feil i dataene dine. Hold musepekeren over feilen for å se
|
||||||
detaljer.
|
detaljer.
|
||||||
errors_notice_mobile: Du har feil i dataene dine. Trykk på feilverktøytipset for å se
|
errors_notice_mobile: Du har feil i dataene dine. Trykk på feilverktøytipset for å se
|
||||||
detaljer.
|
detaljer.
|
||||||
title: Rengjør dataene dine
|
title: Rengjør dataene dine
|
||||||
configurations:
|
configurations:
|
||||||
mint_import:
|
mint_import:
|
||||||
date_format_label: Datoformat
|
date_format_label: Datoformat
|
||||||
show:
|
show:
|
||||||
description: Velg kolonnene som tilsvarer hvert felt i CSV-filen din.
|
description: Velg kolonnene som tilsvarer hvert felt i CSV-filen din.
|
||||||
title: Konfigurer importen din
|
title: Konfigurer importen din
|
||||||
trade_import:
|
trade_import:
|
||||||
date_format_label: Datoformat
|
date_format_label: Datoformat
|
||||||
transaction_import:
|
transaction_import:
|
||||||
date_format_label: Datoformat
|
date_format_label: Datoformat
|
||||||
confirms:
|
confirms:
|
||||||
mappings:
|
mappings:
|
||||||
create_account: Opprett konto
|
create_account: Opprett konto
|
||||||
csv_mapping_label: "%{mapping} i CSV"
|
csv_mapping_label: "%{mapping} i CSV"
|
||||||
maybe_mapping_label: "%{mapping} i Sure"
|
maybe_mapping_label: "%{mapping} i Sure"
|
||||||
no_accounts: Du har ingen kontoer ennå. Vennligst opprett en konto som
|
no_accounts: Du har ingen kontoer ennå. Vennligst opprett en konto som
|
||||||
vi kan bruke for (utilordnede) rader i CSV-filen din eller gå tilbake til Rengjør-trinnet
|
vi kan bruke for (utilordnede) rader i CSV-filen din eller gå tilbake til Rengjør-trinnet
|
||||||
og oppgi et kontonavn vi kan bruke.
|
og oppgi et kontonavn vi kan bruke.
|
||||||
rows_label: Rader
|
rows_label: Rader
|
||||||
unassigned_account: Trenger å opprette en ny konto for utilordnede rader?
|
unassigned_account: Trenger å opprette en ny konto for utilordnede rader?
|
||||||
show:
|
show:
|
||||||
account_mapping_description: Tilordne alle kontoene i den importerte filen din til
|
account_mapping_description: Tilordne alle kontoene i den importerte filen din til
|
||||||
Maybes eksisterende kontoer. Du kan også legge til nye kontoer eller la dem
|
Maybes eksisterende kontoer. Du kan også legge til nye kontoer eller la dem
|
||||||
være ukategorisert.
|
være ukategorisert.
|
||||||
account_mapping_title: Tilordne kontoene dine
|
account_mapping_title: Tilordne kontoene dine
|
||||||
account_type_mapping_description: Tilordne alle kontotypene i den importerte filen din til
|
account_type_mapping_description: Tilordne alle kontotypene i den importerte filen din til
|
||||||
Maybes
|
Maybes
|
||||||
account_type_mapping_title: Tilordne kontotypene dine
|
account_type_mapping_title: Tilordne kontotypene dine
|
||||||
category_mapping_description: Tilordne alle kategoriene i den importerte filen din til
|
category_mapping_description: Tilordne alle kategoriene i den importerte filen din til
|
||||||
Maybes eksisterende kategorier. Du kan også legge til nye kategorier eller la dem
|
Maybes eksisterende kategorier. Du kan også legge til nye kategorier eller la dem
|
||||||
være ukategorisert.
|
være ukategorisert.
|
||||||
category_mapping_title: Tilordne kategoriene dine
|
category_mapping_title: Tilordne kategoriene dine
|
||||||
tag_mapping_description: Tilordne alle tagene i den importerte filen din til
|
tag_mapping_description: Tilordne alle tagene i den importerte filen din til
|
||||||
Maybes eksisterende tagger. Du kan også legge til nye tagger eller la dem
|
Maybes eksisterende tagger. Du kan også legge til nye tagger eller la dem
|
||||||
være ukategorisert.
|
være ukategorisert.
|
||||||
tag_mapping_title: Tilordne tagene dine
|
tag_mapping_title: Tilordne tagene dine
|
||||||
uploads:
|
uploads:
|
||||||
show:
|
show:
|
||||||
description: Lim inn eller last opp CSV-filen din nedenfor. Vennligst gjennomgå instruksjonene
|
description: Lim inn eller last opp CSV-filen din nedenfor. Vennligst gjennomgå instruksjonene
|
||||||
i tabellen nedenfor før du begynner.
|
i tabellen nedenfor før du begynner.
|
||||||
instructions_1: Nedenfor er et eksempel på CSV med kolonner tilgjengelig for import.
|
instructions_1: Nedenfor er et eksempel på CSV med kolonner tilgjengelig for import.
|
||||||
instructions_2: CSV-filen din må ha en overskriftsrad
|
instructions_2: CSV-filen din må ha en overskriftsrad
|
||||||
instructions_3: Du kan navngi kolonnene dine hva du vil. Du vil tilordne
|
instructions_3: Du kan navngi kolonnene dine hva du vil. Du vil tilordne
|
||||||
dem på et senere trinn.
|
dem på et senere trinn.
|
||||||
instructions_4: Kolonner merket med en stjerne (*) er obligatoriske data.
|
instructions_4: Kolonner merket med en stjerne (*) er obligatoriske data.
|
||||||
instructions_5: Ingen komma, ingen valutasymboler og ingen parenteser i tall.
|
instructions_5: Ingen komma, ingen valutasymboler og ingen parenteser i tall.
|
||||||
title: Importer dataene dine
|
title: Importer dataene dine
|
||||||
imports:
|
imports:
|
||||||
empty:
|
empty:
|
||||||
message: Ingen importer ennå.
|
message: Ingen importer ennå.
|
||||||
new: Ny import
|
new: Ny Import
|
||||||
import:
|
import:
|
||||||
complete: Fullført
|
complete: Fullført
|
||||||
delete: Slett
|
delete: Slett
|
||||||
failed: Mislykket
|
failed: Mislykket
|
||||||
in_progress: Pågår
|
in_progress: Pågår
|
||||||
label: "%{type}: %{datetime}"
|
label: "%{type}: %{datetime}"
|
||||||
revert_failed: Tilbakestilling mislykket
|
revert_failed: Tilbakestilling mislykket
|
||||||
reverting: Tilbakestiller
|
reverting: Tilbakestiller
|
||||||
uploading: Behandler rader
|
uploading: Behandler rader
|
||||||
view: Vis
|
view: Vis
|
||||||
index:
|
index:
|
||||||
imports: Importer
|
imports: Importer
|
||||||
new: Ny import
|
new: Ny Import
|
||||||
title: Importer
|
title: Importer
|
||||||
new:
|
exports: Eksporter
|
||||||
description: Du kan manuelt importere ulike typer data via CSV eller bruke en av
|
new_export: Ny Eksport
|
||||||
våre importmaler som Mint.
|
no_exports: Ingen eksporter ennå.
|
||||||
import_accounts: Importer kontoer
|
new:
|
||||||
import_mint: Importer fra Mint
|
description: Du kan manuelt importere ulike typer data via CSV eller bruke en av
|
||||||
import_portfolio: Importer investeringer
|
våre importmaler som Mint.
|
||||||
import_transactions: Importer transaksjoner
|
import_accounts: Importer kontoer
|
||||||
resume: Fortsett %{type}
|
import_mint: Importer fra Mint
|
||||||
sources: Kilder
|
import_portfolio: Importer investeringer
|
||||||
title: Ny CSV-import
|
import_transactions: Importer transaksjoner
|
||||||
ready:
|
resume: Fortsett %{type}
|
||||||
description: Her er en oppsummering av de nye elementene som vil bli lagt til kontoen din
|
sources: Kilder
|
||||||
når du publiserer denne importen.
|
title: Ny CSV-import
|
||||||
|
ready:
|
||||||
|
description: Her er en oppsummering av de nye elementene som vil bli lagt til kontoen din
|
||||||
|
når du publiserer denne importen.
|
||||||
title: Bekreft importdataene dine
|
title: Bekreft importdataene dine
|
||||||
@@ -46,7 +46,7 @@ tr:
|
|||||||
imports:
|
imports:
|
||||||
empty:
|
empty:
|
||||||
message: Henüz hiç içe aktarma yok.
|
message: Henüz hiç içe aktarma yok.
|
||||||
new: Yeni içe aktarma
|
new: Yeni İçe Aktarma
|
||||||
import:
|
import:
|
||||||
complete: Tamamlandı
|
complete: Tamamlandı
|
||||||
delete: Sil
|
delete: Sil
|
||||||
@@ -59,8 +59,11 @@ tr:
|
|||||||
view: Görüntüle
|
view: Görüntüle
|
||||||
index:
|
index:
|
||||||
imports: İçe aktarmalar
|
imports: İçe aktarmalar
|
||||||
new: Yeni içe aktarma
|
new: Yeni İçe Aktarma
|
||||||
title: İçe aktarmalar
|
title: İçe aktarmalar
|
||||||
|
exports: Dışa aktarmalar
|
||||||
|
new_export: Yeni Dışa Aktarma
|
||||||
|
no_exports: Henüz hiç dışa aktarma yok.
|
||||||
new:
|
new:
|
||||||
description: Farklı veri türlerini CSV ile manuel olarak içe aktarabilir veya Mint gibi içe aktarma şablonlarımızı kullanabilirsiniz.
|
description: Farklı veri türlerini CSV ile manuel olarak içe aktarabilir veya Mint gibi içe aktarma şablonlarımızı kullanabilirsiniz.
|
||||||
import_accounts: Hesapları içe aktar
|
import_accounts: Hesapları içe aktar
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ en:
|
|||||||
show:
|
show:
|
||||||
title: "API Key Management"
|
title: "API Key Management"
|
||||||
no_api_key:
|
no_api_key:
|
||||||
title: "Create Your API Key"
|
title: "API Key"
|
||||||
description: "Get programmatic access to your Maybe data with a secure 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:"
|
what_you_can_do: "What you can do with the API:"
|
||||||
feature_1: "Access your account data programmatically"
|
feature_1: "Access your account data programmatically"
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
---
|
---
|
||||||
en:
|
en:
|
||||||
settings:
|
settings:
|
||||||
|
ai_prompts:
|
||||||
|
show:
|
||||||
|
page_title: AI Prompts
|
||||||
|
openai_label: OpenAI
|
||||||
|
prompt_instructions: Prompt Instructions
|
||||||
|
main_system_prompt:
|
||||||
|
title: Main System Prompt
|
||||||
|
subtitle: Core instructions that define how the AI assistant behaves in all chat conversations
|
||||||
|
transaction_categorizer:
|
||||||
|
title: Transaction Categorizer
|
||||||
|
subtitle: AI automatically categorizes your transactions based on your defined categories
|
||||||
|
merchant_detector:
|
||||||
|
title: Merchant Detector
|
||||||
|
subtitle: AI identifies and enriches transaction data with merchant information
|
||||||
billings:
|
billings:
|
||||||
show:
|
show:
|
||||||
page_title: Billing
|
page_title: Billing
|
||||||
@@ -59,10 +73,10 @@ en:
|
|||||||
invitation_link: Invitation link
|
invitation_link: Invitation link
|
||||||
invite_member: Add member
|
invite_member: Add member
|
||||||
last_name: Last Name
|
last_name: Last Name
|
||||||
page_title: Account
|
page_title: Profile Info
|
||||||
pending: Pending
|
pending: Pending
|
||||||
profile_subtitle: Customize how you appear on Sure
|
profile_subtitle: Customize how you appear on Sure
|
||||||
profile_title: Profile
|
profile_title: Personal
|
||||||
remove_invitation: Remove Invitation
|
remove_invitation: Remove Invitation
|
||||||
remove_member: Remove Member
|
remove_member: Remove Member
|
||||||
save: Save
|
save: Save
|
||||||
@@ -71,24 +85,27 @@ en:
|
|||||||
page_title: Security
|
page_title: Security
|
||||||
settings_nav:
|
settings_nav:
|
||||||
accounts_label: Accounts
|
accounts_label: Accounts
|
||||||
|
advanced_section_title: Advanced
|
||||||
|
ai_prompts_label: AI Prompts
|
||||||
api_key_label: API Key
|
api_key_label: API Key
|
||||||
billing_label: Billing
|
billing_label: Billing
|
||||||
categories_label: Categories
|
categories_label: Categories
|
||||||
feedback_label: Feedback
|
feedback_label: Feedback
|
||||||
general_section_title: General
|
general_section_title: General
|
||||||
imports_label: Imports
|
imports_label: Import/Export
|
||||||
logout: Logout
|
logout: Logout
|
||||||
merchants_label: Merchants
|
merchants_label: Merchants
|
||||||
|
guides_label: Guides
|
||||||
other_section_title: More
|
other_section_title: More
|
||||||
preferences_label: Preferences
|
preferences_label: Preferences
|
||||||
profile_label: Account
|
profile_label: Profile Info
|
||||||
rules_label: Rules
|
rules_label: Rules
|
||||||
security_label: Security
|
security_label: Security
|
||||||
self_hosting_label: Self-Hosting
|
self_hosting_label: Self-Hosting
|
||||||
tags_label: Tags
|
tags_label: Tags
|
||||||
transactions_section_title: Transactions
|
transactions_section_title: Transactions
|
||||||
whats_new_label: What's new
|
whats_new_label: What's new
|
||||||
api_keys_label: API Keys
|
api_keys_label: API Key
|
||||||
bank_sync_label: Bank Sync
|
bank_sync_label: Bank Sync
|
||||||
settings_nav_link_large:
|
settings_nav_link_large:
|
||||||
next: Next
|
next: Next
|
||||||
|
|||||||
@@ -1,96 +1,97 @@
|
|||||||
---
|
---
|
||||||
nb:
|
nb:
|
||||||
settings:
|
settings:
|
||||||
billings:
|
billings:
|
||||||
show:
|
show:
|
||||||
page_title: Fakturering
|
page_title: Fakturering
|
||||||
subscription_subtitle: Oppdater abonnementet og faktureringsdetaljene dine
|
subscription_subtitle: Oppdater abonnementet og faktureringsdetaljene dine
|
||||||
subscription_title: Administrer abonnement
|
subscription_title: Administrer abonnement
|
||||||
preferences:
|
preferences:
|
||||||
show:
|
show:
|
||||||
country: Land
|
country: Land
|
||||||
currency: Valuta
|
currency: Valuta
|
||||||
date_format: Datoformat
|
date_format: Datoformat
|
||||||
general_subtitle: Konfigurer preferansene dine
|
general_subtitle: Konfigurer preferansene dine
|
||||||
general_title: Generelt
|
general_title: Generelt
|
||||||
default_period: Standardperiode
|
default_period: Standardperiode
|
||||||
language: Språk
|
language: Språk
|
||||||
page_title: Preferanser
|
page_title: Preferanser
|
||||||
theme_dark: Mørk
|
theme_dark: Mørk
|
||||||
theme_light: Lys
|
theme_light: Lys
|
||||||
theme_subtitle: Velg et foretrukket tema for appen
|
theme_subtitle: Velg et foretrukket tema for appen
|
||||||
theme_system: System
|
theme_system: System
|
||||||
theme_title: Tema
|
theme_title: Tema
|
||||||
timezone: Tidssone
|
timezone: Tidssone
|
||||||
profiles:
|
profiles:
|
||||||
destroy:
|
destroy:
|
||||||
cannot_remove_self: Du kan ikke fjerne deg selv fra din egen konto.
|
cannot_remove_self: Du kan ikke fjerne deg selv fra din egen konto.
|
||||||
member_removal_failed: Det oppsto et problem med å fjerne medlemmet.
|
member_removal_failed: Det oppsto et problem med å fjerne medlemmet.
|
||||||
member_removed: Medlemmet ble fjernet vellykket.
|
member_removed: Medlemmet ble fjernet vellykket.
|
||||||
not_authorized: Du er ikke autorisert til å fjerne medlemmer.
|
not_authorized: Du er ikke autorisert til å fjerne medlemmer.
|
||||||
show:
|
show:
|
||||||
confirm_delete:
|
confirm_delete:
|
||||||
body: Er du sikker på at du vil permanent slette kontoen din? Denne handlingen er irreversibel.
|
body: Er du sikker på at du vil permanent slette kontoen din? Denne handlingen er irreversibel.
|
||||||
title: Slett konto?
|
title: Slett konto?
|
||||||
confirm_reset:
|
confirm_reset:
|
||||||
body: Er du sikker på at du vil tilbakestille kontoen din? Dette vil slette alle kontoene dine, kategorier, forhandlere, tagger og andre data. Denne handlingen kan ikke angres.
|
body: Er du sikker på at du vil tilbakestille kontoen din? Dette vil slette alle kontoene dine, kategorier, forhandlere, tagger og andre data. Denne handlingen kan ikke angres.
|
||||||
title: Tilbakestill konto?
|
title: Tilbakestill konto?
|
||||||
confirm_remove_invitation:
|
confirm_remove_invitation:
|
||||||
body: Er du sikker på at du vil fjerne invitasjonen for %{email}?
|
body: Er du sikker på at du vil fjerne invitasjonen for %{email}?
|
||||||
title: Fjern invitasjon
|
title: Fjern invitasjon
|
||||||
confirm_remove_member:
|
confirm_remove_member:
|
||||||
body: Er du sikker på at du vil fjerne %{name} fra kontoen din?
|
body: Er du sikker på at du vil fjerne %{name} fra kontoen din?
|
||||||
title: Fjern medlem
|
title: Fjern medlem
|
||||||
danger_zone_title: Fareområde
|
danger_zone_title: Fareområde
|
||||||
delete_account: Slett konto
|
delete_account: Slett konto
|
||||||
delete_account_warning: Sletting av kontoen din vil permanent fjerne alle
|
delete_account_warning: Sletting av kontoen din vil permanent fjerne alle
|
||||||
dataene dine og kan ikke angres.
|
dataene dine og kan ikke angres.
|
||||||
reset_account: Tilbakestill konto
|
reset_account: Tilbakestill konto
|
||||||
reset_account_warning: Tilbakestilling av kontoen din vil slette alle kontoene dine, kategorier, forhandlere, tagger og andre data, men beholde brukerkontoen din intakt.
|
reset_account_warning: Tilbakestilling av kontoen din vil slette alle kontoene dine, kategorier, forhandlere, tagger og andre data, men beholde brukerkontoen din intakt.
|
||||||
email: E-post
|
email: E-post
|
||||||
first_name: Fornavn
|
first_name: Fornavn
|
||||||
household_form_input_placeholder: Angi husholdningsnavn
|
household_form_input_placeholder: Angi husholdningsnavn
|
||||||
household_form_label: Husholdningsnavn
|
household_form_label: Husholdningsnavn
|
||||||
household_subtitle: Inviter familiemedlemmer, partnere og andre individer. Inviterte
|
household_subtitle: Inviter familiemedlemmer, partnere og andre individer. Inviterte
|
||||||
kan logge inn på husholdningen din og få tilgang til dine delte kontoer.
|
kan logge inn på husholdningen din og få tilgang til dine delte kontoer.
|
||||||
household_title: Husholdning
|
household_title: Husholdning
|
||||||
invitation_link: Invitasjonslenke
|
invitation_link: Invitasjonslenke
|
||||||
invite_member: Legg til medlem
|
invite_member: Legg til medlem
|
||||||
last_name: Etternavn
|
last_name: Etternavn
|
||||||
page_title: Konto
|
page_title: Konto
|
||||||
pending: Venter
|
pending: Venter
|
||||||
profile_subtitle: Tilpass hvordan du vises på Sure
|
profile_subtitle: Tilpass hvordan du vises på Sure
|
||||||
profile_title: Profil
|
profile_title: Profil
|
||||||
remove_invitation: Fjern invitasjon
|
remove_invitation: Fjern invitasjon
|
||||||
remove_member: Fjern medlem
|
remove_member: Fjern medlem
|
||||||
save: Lagre
|
save: Lagre
|
||||||
securities:
|
securities:
|
||||||
show:
|
show:
|
||||||
page_title: Sikkerhet
|
page_title: Sikkerhet
|
||||||
settings_nav:
|
settings_nav:
|
||||||
accounts_label: Kontoer
|
accounts_label: Kontoer
|
||||||
api_key_label: API-nøkkel
|
api_key_label: API-nøkkel
|
||||||
billing_label: Fakturering
|
advanced_section_title: Avansert
|
||||||
categories_label: Kategorier
|
billing_label: Fakturering
|
||||||
feedback_label: Tilbakemelding
|
categories_label: Kategorier
|
||||||
general_section_title: Generelt
|
feedback_label: Tilbakemelding
|
||||||
imports_label: Importer
|
general_section_title: Generelt
|
||||||
logout: Logg ut
|
imports_label: Importer
|
||||||
merchants_label: Forhandlere
|
logout: Logg ut
|
||||||
other_section_title: Mer
|
merchants_label: Forhandlere
|
||||||
preferences_label: Preferanser
|
other_section_title: Mer
|
||||||
profile_label: Konto
|
preferences_label: Preferanser
|
||||||
rules_label: Regler
|
profile_label: Konto
|
||||||
security_label: Sikkerhet
|
rules_label: Regler
|
||||||
self_hosting_label: Selvhosting
|
security_label: Sikkerhet
|
||||||
tags_label: Tagger
|
self_hosting_label: Selvhosting
|
||||||
transactions_section_title: Transaksjoner
|
tags_label: Tagger
|
||||||
whats_new_label: Hva er nytt
|
transactions_section_title: Transaksjoner
|
||||||
settings_nav_link_large:
|
whats_new_label: Hva er nytt
|
||||||
next: Neste
|
settings_nav_link_large:
|
||||||
previous: Tilbake
|
next: Neste
|
||||||
user_avatar_field:
|
previous: Tilbake
|
||||||
accepted_formats: JPG eller PNG. 5MB maks.
|
user_avatar_field:
|
||||||
choose: Last opp bilde
|
accepted_formats: JPG eller PNG. 5MB maks.
|
||||||
choose_label: (valgfritt)
|
choose: Last opp bilde
|
||||||
|
choose_label: (valgfritt)
|
||||||
change: Endre bilde
|
change: Endre bilde
|
||||||
@@ -68,6 +68,7 @@ tr:
|
|||||||
settings_nav:
|
settings_nav:
|
||||||
accounts_label: Hesaplar
|
accounts_label: Hesaplar
|
||||||
api_key_label: API Anahtarı
|
api_key_label: API Anahtarı
|
||||||
|
advanced_section_title: Gelişmiş
|
||||||
billing_label: Faturalandırma
|
billing_label: Faturalandırma
|
||||||
categories_label: Kategoriler
|
categories_label: Kategoriler
|
||||||
feedback_label: Geri Bildirim
|
feedback_label: Geri Bildirim
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :family_exports, only: %i[new create index] do
|
resources :family_exports, only: %i[new create index destroy] do
|
||||||
member do
|
member do
|
||||||
get :download
|
get :download
|
||||||
end
|
end
|
||||||
@@ -63,6 +63,8 @@ Rails.application.routes.draw do
|
|||||||
resource :billing, only: :show
|
resource :billing, only: :show
|
||||||
resource :security, only: :show
|
resource :security, only: :show
|
||||||
resource :api_key, only: [ :show, :new, :create, :destroy ]
|
resource :api_key, only: [ :show, :new, :create, :destroy ]
|
||||||
|
resource :ai_prompts, only: :show
|
||||||
|
resource :guides, only: :show
|
||||||
resource :bank_sync, only: :show, controller: "bank_sync"
|
resource :bank_sync, only: :show, controller: "bank_sync"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
160
docs/onboarding/guide.md
Normal file
160
docs/onboarding/guide.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# Welcome to Sure!
|
||||||
|
|
||||||
|
This guide aims to assist new users through:
|
||||||
|
|
||||||
|
1. Creating a Sure account
|
||||||
|
2. Adding your first accounts
|
||||||
|
3. Recording transactions
|
||||||
|
|
||||||
|
This guide also covers the differences between **asset** and **liability** accounts, a key concept for using and understanding balances in Sure!
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Sure is evolving quickly. If you find something inaccurate while following this guide, please:
|
||||||
|
>
|
||||||
|
> - Ask in the [Discord](https://discord.gg/36ZGBsxYEK)
|
||||||
|
> - Open an [issue](https://github.com/we-promise/sure/issues/new/choose)
|
||||||
|
> - Or if you know the answer, open a [PR](https://github.com/we-promise/sure/compare)!
|
||||||
|
|
||||||
|
|
||||||
|
## 1. Creating your Sure Account
|
||||||
|
|
||||||
|
Once Sure is installed, open a browser and navigate to [localhost:3000](http://localhost:3000/sessions/new).<br />
|
||||||
|
You will see the **login page** (pictured below). Since we do not have an account yet, click on **Sign Up** to begin.
|
||||||
|
|
||||||
|
<img width="2508" height="1314" alt="Landing page on a fresh install." src="https://github.com/user-attachments/assets/2319dc87-5615-4473-bebc-8360dd983367" />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
|
||||||
|
You’ll be guided through a short series of screens to set your **login details**, **personal information**, and **preferences**.<br />
|
||||||
|
When you arrive at the main dashboard, showing **No accounts yet**, you’re all set up!
|
||||||
|
|
||||||
|
<img width="2508" height="1314" alt="Blank home screen of Sure, with no accounts yet." src="https://github.com/user-attachments/assets/f06ba8e2-f188-4bf9-98a7-fdef724e9b5a" />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
|
||||||
|
> [!Note]
|
||||||
|
> The next sections of this guide cover how to **manually add accounts and transactions** in Sure.<br />
|
||||||
|
> If you’d like to use an integration with a data provider instead, see:
|
||||||
|
>
|
||||||
|
> - **Lunch Flow** (WIP)
|
||||||
|
> - [**Plaid**](/docs/hosting/plaid.md)
|
||||||
|
> - **SimpleFin** (WIP)
|
||||||
|
>
|
||||||
|
> Even if you use an integration, we still recommend reading through this guide to understand **account types** and how they work in Sure.
|
||||||
|
|
||||||
|
|
||||||
|
## 2. Account Types in Sure
|
||||||
|
|
||||||
|
Sure supports several account types, which are grouped into **Assets** (things you own) and **Debts/Liabilities** (things you owe):
|
||||||
|
|
||||||
|
| Assets | Debts/Liabilities |
|
||||||
|
| ----------- | ----------------- |
|
||||||
|
| Cash | Credit Card |
|
||||||
|
| Investment | Loan |
|
||||||
|
| Crypto | Other Liability |
|
||||||
|
| Property | |
|
||||||
|
| Vehicle | |
|
||||||
|
| Other Asset | |
|
||||||
|
|
||||||
|
|
||||||
|
## 3. How Asset Accounts Work
|
||||||
|
|
||||||
|
Cash, checking and savings accounts **increase** when you add money and **decrease** when you spend money.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
- Starting balance: $500
|
||||||
|
- Add an expense of $20 -> balance is now $480
|
||||||
|
- Add an income of $100 -> balance is now $580
|
||||||
|
|
||||||
|
|
||||||
|
## 4. How Debt Accounts Work (Liabilities)
|
||||||
|
|
||||||
|
Liability accounts track how much money you **owe**, so the math can feel *backwards* compared to an asset account.
|
||||||
|
|
||||||
|
**Key rule:**
|
||||||
|
|
||||||
|
- **Positive Balances** = you owe money
|
||||||
|
- **Negative balances** = the bank owes *you* (e.g. overpayment or refund)
|
||||||
|
|
||||||
|
**Transactions behave like this:**
|
||||||
|
|
||||||
|
- **Expenses** (e.g. purchases) => increase your debt (you owe more)
|
||||||
|
- **Payments or refunds** => decrease your debt (you owe less)
|
||||||
|
|
||||||
|
Credit Card example:
|
||||||
|
|
||||||
|
1. Balance: **$200 owed**
|
||||||
|
2. Spend $20 => You now owe $220 (balance goes *up* in red)
|
||||||
|
3. Pay off $50 => You now owe $170 (balance goes *down* in green)
|
||||||
|
|
||||||
|
Overpayment Example:
|
||||||
|
|
||||||
|
1. Balance: -$44 (bank owes you $44)
|
||||||
|
2. Spend $1 => Bank now owes you **$43** (balance shown as -$43, moving towards zero)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Why does it work this way? This matches standard accounting and what your credit card provider shows online. Think of a liability balance as "**Amount Owed**", not "available cash."
|
||||||
|
|
||||||
|
|
||||||
|
## 5. Quick Reference: Assets vs. Liability Behavior
|
||||||
|
|
||||||
|
| Action | Asset Account (e.g. Checking) | Liability Account (e.g. Credit Card) |
|
||||||
|
| ---------------- | ----------------------------- | ------------------------------------ |
|
||||||
|
| Spend $20 | Balance ↓ $20 | Balance ↑ $20 (more debt) |
|
||||||
|
| Receive $50 | Balance ↑ $50 | Balance ↓ $50 (less debt) |
|
||||||
|
| Negative Balance | Overdraft | Bank owes *you* money |
|
||||||
|
|
||||||
|
|
||||||
|
## 6. Adding Accounts
|
||||||
|
|
||||||
|
For this example we'll add a **Savings Account**.<br />
|
||||||
|
|
||||||
|
>[!TIP]
|
||||||
|
>If you’re adding a **credit card**, **loan**, or any other **debt**, be sure to select a **Credit Card** or **Liability** account type instead of **Cash**. This will ensure balances update correctly and match what your bank shows.
|
||||||
|
|
||||||
|
Most bank accounts (checking, savings, money market) are **Cash Accounts**
|
||||||
|
1. Click **+ Add Account** → **Cash** → **Enter Account Balance**
|
||||||
|
2. Fill in details such as:
|
||||||
|
- Account name
|
||||||
|
- Current Balance
|
||||||
|
- Account Subtype (This is where you specify checking, savings, or other)
|
||||||
|
3. Click **Create Account** when you are ready to proceed.
|
||||||
|
|
||||||
|
<img width="500" height="303" alt="Cash Account creation menu" src="https://github.com/user-attachments/assets/e564a447-c85e-403e-979b-efe770ea2a61" />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
|
||||||
|
Once created, you'll return to the **Home** screen.<br />
|
||||||
|
You'll now see:
|
||||||
|
- Your new cash account in the **Accounts** list (left side)
|
||||||
|
- An overview of your accounts in the center, under the net worth bar.
|
||||||
|
|
||||||
|
To get this bar moving let's add some transactions!
|
||||||
|
|
||||||
|
<img width="2508" height="1314" alt="Home screen of Sure, showing one account and no transactions." src="https://github.com/user-attachments/assets/7766a0cd-6b20-48f0-9ba2-87dfddd77236" />
|
||||||
|
|
||||||
|
## 7. Adding Transactions
|
||||||
|
|
||||||
|
To add a transaction:
|
||||||
|
1. Go to the **Transactions** page (left sidebar, under **Home**, above **Budgets**)
|
||||||
|
2. Click **+ New Transaction** (top right)
|
||||||
|
3. Choose the transaction type:
|
||||||
|
- **Expense** → Spending money
|
||||||
|
- **Income** → Receiving money
|
||||||
|
- **Transfer** → Move money between accounts
|
||||||
|
4. Enter the details, then click **Add transaction**
|
||||||
|
|
||||||
|
You will now see the transaction you added in your **transaction history**, as well as the **net worth chart** updating accordingly.
|
||||||
|
|
||||||
|
<img width="500" height="512" alt="Filled-out expense form" src="https://github.com/user-attachments/assets/7c1d38d1-edb8-4d12-8b3e-bbef4836cc92" />
|
||||||
|
|
||||||
|
## 8. Next Steps
|
||||||
|
|
||||||
|
Now that you have one account and your first transaction:
|
||||||
|
- Explore the other account types that Sure offers, adding ones relevant to your finances.
|
||||||
|
- **Categorize** and **Tag** transactions for better searching and reporting.
|
||||||
|
- Experiment with **Budgets** to track your spending habits.
|
||||||
|
- If you have many historical transactions, use **Bulk Import** to load them in.
|
||||||
|
|
||||||
|
More detailed user guides for these features are coming soon™.
|
||||||
@@ -33,7 +33,7 @@ class FamilyExportsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
post family_exports_path
|
post family_exports_path
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_redirected_to settings_profile_path
|
assert_redirected_to imports_path
|
||||||
assert_equal "Export started. You'll be able to download it shortly.", flash[:notice]
|
assert_equal "Export started. You'll be able to download it shortly.", flash[:notice]
|
||||||
|
|
||||||
export = @family.family_exports.last
|
export = @family.family_exports.last
|
||||||
@@ -67,7 +67,87 @@ class FamilyExportsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
export = @family.family_exports.create!(status: "processing")
|
export = @family.family_exports.create!(status: "processing")
|
||||||
|
|
||||||
get download_family_export_path(export)
|
get download_family_export_path(export)
|
||||||
assert_redirected_to settings_profile_path
|
assert_redirected_to imports_path
|
||||||
assert_equal "Export not ready for download", flash[:alert]
|
assert_equal "Export not ready for download", flash[:alert]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "admin can delete export" do
|
||||||
|
export = @family.family_exports.create!(status: "completed")
|
||||||
|
|
||||||
|
assert_difference "@family.family_exports.count", -1 do
|
||||||
|
delete family_export_path(export)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to imports_path
|
||||||
|
assert_equal "Export deleted successfully", flash[:notice]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin can delete export with attached file" do
|
||||||
|
export = @family.family_exports.create!(status: "completed")
|
||||||
|
export.export_file.attach(
|
||||||
|
io: StringIO.new("test zip content"),
|
||||||
|
filename: "test.zip",
|
||||||
|
content_type: "application/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert export.export_file.attached?
|
||||||
|
assert_difference "@family.family_exports.count", -1 do
|
||||||
|
delete family_export_path(export)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to imports_path
|
||||||
|
assert_equal "Export deleted successfully", flash[:notice]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin can delete failed export with attached file" do
|
||||||
|
export = @family.family_exports.create!(status: "failed")
|
||||||
|
export.export_file.attach(
|
||||||
|
io: StringIO.new("failed export content"),
|
||||||
|
filename: "failed.zip",
|
||||||
|
content_type: "application/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert export.export_file.attached?
|
||||||
|
assert_difference "@family.family_exports.count", -1 do
|
||||||
|
delete family_export_path(export)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to imports_path
|
||||||
|
assert_equal "Export deleted successfully", flash[:notice]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "export file is purged when export is deleted" do
|
||||||
|
export = @family.family_exports.create!(status: "completed")
|
||||||
|
export.export_file.attach(
|
||||||
|
io: StringIO.new("test zip content"),
|
||||||
|
filename: "test.zip",
|
||||||
|
content_type: "application/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify file is attached
|
||||||
|
assert export.export_file.attached?
|
||||||
|
file_id = export.export_file.id
|
||||||
|
|
||||||
|
# Delete the export
|
||||||
|
delete family_export_path(export)
|
||||||
|
|
||||||
|
# Verify the export record is gone
|
||||||
|
assert_not FamilyExport.exists?(export.id)
|
||||||
|
|
||||||
|
# Verify the Active Storage attachment is also gone
|
||||||
|
# Note: Active Storage purges files asynchronously with `dependent: :purge_later`
|
||||||
|
# In tests, we can check that the attachment record is gone
|
||||||
|
assert_not ActiveStorage::Attachment.exists?(file_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-admin cannot delete export" do
|
||||||
|
export = @family.family_exports.create!(status: "completed")
|
||||||
|
sign_in @non_admin
|
||||||
|
|
||||||
|
assert_no_difference "@family.family_exports.count" do
|
||||||
|
delete family_export_path(export)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to root_path
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,131 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class FamilyExportTest < ActiveSupport::TestCase
|
class FamilyExportTest < ActiveSupport::TestCase
|
||||||
# test "the truth" do
|
setup do
|
||||||
# assert true
|
@family = families(:dylan_family)
|
||||||
# end
|
@export = @family.family_exports.create!
|
||||||
|
end
|
||||||
|
|
||||||
|
test "belongs to family" do
|
||||||
|
assert_equal @family, @export.family
|
||||||
|
end
|
||||||
|
|
||||||
|
test "has default status of pending" do
|
||||||
|
assert_equal "pending", @export.status
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can have export file attached" do
|
||||||
|
@export.export_file.attach(
|
||||||
|
io: StringIO.new("test content"),
|
||||||
|
filename: "test.zip",
|
||||||
|
content_type: "application/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert @export.export_file.attached?
|
||||||
|
assert_equal "test.zip", @export.export_file.filename.to_s
|
||||||
|
assert_equal "application/zip", @export.export_file.content_type
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filename is generated correctly" do
|
||||||
|
travel_to Time.zone.local(2024, 1, 15, 14, 30, 0) do
|
||||||
|
export = @family.family_exports.create!
|
||||||
|
expected_filename = "maybe_export_20240115_143000.zip"
|
||||||
|
assert_equal expected_filename, export.filename
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "downloadable? returns true for completed export with file" do
|
||||||
|
@export.update!(status: "completed")
|
||||||
|
@export.export_file.attach(
|
||||||
|
io: StringIO.new("test content"),
|
||||||
|
filename: "test.zip",
|
||||||
|
content_type: "application/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert @export.downloadable?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "downloadable? returns false for pending export" do
|
||||||
|
@export.update!(status: "pending")
|
||||||
|
@export.export_file.attach(
|
||||||
|
io: StringIO.new("test content"),
|
||||||
|
filename: "test.zip",
|
||||||
|
content_type: "application/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_not @export.downloadable?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "downloadable? returns false for completed export without file" do
|
||||||
|
@export.update!(status: "completed")
|
||||||
|
|
||||||
|
assert_not @export.downloadable?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "downloadable? returns false for failed export with file" do
|
||||||
|
@export.update!(status: "failed")
|
||||||
|
@export.export_file.attach(
|
||||||
|
io: StringIO.new("test content"),
|
||||||
|
filename: "test.zip",
|
||||||
|
content_type: "application/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_not @export.downloadable?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "export file is purged when export is destroyed" do
|
||||||
|
@export.export_file.attach(
|
||||||
|
io: StringIO.new("test content"),
|
||||||
|
filename: "test.zip",
|
||||||
|
content_type: "application/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify file is attached
|
||||||
|
assert @export.export_file.attached?
|
||||||
|
file_id = @export.export_file.id
|
||||||
|
blob_id = @export.export_file.blob.id
|
||||||
|
|
||||||
|
# Destroy the export
|
||||||
|
@export.destroy!
|
||||||
|
|
||||||
|
# Verify the export record is gone
|
||||||
|
assert_not FamilyExport.exists?(@export.id)
|
||||||
|
|
||||||
|
# Verify the Active Storage attachment is gone
|
||||||
|
assert_not ActiveStorage::Attachment.exists?(file_id)
|
||||||
|
|
||||||
|
# Note: Active Storage purges blobs asynchronously with dependent: :purge_later
|
||||||
|
# In tests, we can verify the attachment is gone, which is the immediate effect
|
||||||
|
# The blob will be purged in the background
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can transition through statuses" do
|
||||||
|
assert_equal "pending", @export.status
|
||||||
|
|
||||||
|
@export.processing!
|
||||||
|
assert_equal "processing", @export.status
|
||||||
|
|
||||||
|
@export.completed!
|
||||||
|
assert_equal "completed", @export.status
|
||||||
|
|
||||||
|
@export.failed!
|
||||||
|
assert_equal "failed", @export.status
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ordered scope returns exports in descending order" do
|
||||||
|
# Clear existing exports to avoid interference
|
||||||
|
@family.family_exports.destroy_all
|
||||||
|
|
||||||
|
# Create exports with specific timestamps
|
||||||
|
old_export = @family.family_exports.create!
|
||||||
|
old_export.update_column(:created_at, 2.days.ago)
|
||||||
|
|
||||||
|
new_export = @family.family_exports.create!
|
||||||
|
new_export.update_column(:created_at, 1.day.ago)
|
||||||
|
|
||||||
|
ordered_exports = @family.family_exports.ordered.to_a
|
||||||
|
assert_equal 2, ordered_exports.length
|
||||||
|
assert_equal new_export.id, ordered_exports.first.id
|
||||||
|
assert_equal old_export.id, ordered_exports.last.id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase
|
|||||||
test "should show no API key state when user has no active keys" do
|
test "should show no API key state when user has no active keys" do
|
||||||
visit settings_api_key_path
|
visit settings_api_key_path
|
||||||
|
|
||||||
assert_text "Create Your API Key"
|
assert_text "API Key"
|
||||||
assert_text "Get programmatic access to your Maybe data"
|
|
||||||
assert_text "Access your account data programmatically"
|
|
||||||
assert_link "Create API Key", href: new_settings_api_key_path
|
assert_link "Create API Key", href: new_settings_api_key_path
|
||||||
|
assert_text "Access your account data programmatically"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should navigate to create new API key form" do
|
test "should navigate to create new API key form" do
|
||||||
@@ -33,7 +32,7 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase
|
|||||||
fill_in "API Key Name", with: "Test Integration Key"
|
fill_in "API Key Name", with: "Test Integration Key"
|
||||||
choose "Read/Write"
|
choose "Read/Write"
|
||||||
|
|
||||||
click_button "Create API Key"
|
click_button "Save API Key"
|
||||||
|
|
||||||
# Should redirect to show page with the API key details
|
# Should redirect to show page with the API key details
|
||||||
assert_current_path settings_api_key_path
|
assert_current_path settings_api_key_path
|
||||||
@@ -100,7 +99,7 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase
|
|||||||
|
|
||||||
fill_in "API Key Name", with: "New API Key"
|
fill_in "API Key Name", with: "New API Key"
|
||||||
choose "Read Only"
|
choose "Read Only"
|
||||||
click_button "Create API Key"
|
click_button "Save API Key"
|
||||||
|
|
||||||
# Should redirect to show page with new key
|
# Should redirect to show page with new key
|
||||||
assert_text "New API Key"
|
assert_text "New API Key"
|
||||||
@@ -133,8 +132,8 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase
|
|||||||
# Wait for redirect after revoke
|
# Wait for redirect after revoke
|
||||||
assert_no_selector "#confirm-dialog"
|
assert_no_selector "#confirm-dialog"
|
||||||
|
|
||||||
assert_text "Create Your API Key"
|
assert_text "API Key"
|
||||||
assert_text "Get programmatic access to your Maybe data"
|
assert_text "Access your account data programmatically"
|
||||||
|
|
||||||
# Key should be revoked in the database
|
# Key should be revoked in the database
|
||||||
api_key.reload
|
api_key.reload
|
||||||
@@ -167,7 +166,7 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase
|
|||||||
|
|
||||||
# Try to submit without name
|
# Try to submit without name
|
||||||
choose "Read Only"
|
choose "Read Only"
|
||||||
click_button "Create API Key"
|
click_button "Save API Key"
|
||||||
|
|
||||||
# Should stay on form with validation error
|
# Should stay on form with validation error
|
||||||
assert_current_path new_settings_api_key_path
|
assert_current_path new_settings_api_key_path
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ class SettingsTest < ApplicationSystemTestCase
|
|||||||
sign_in @user = users(:family_admin)
|
sign_in @user = users(:family_admin)
|
||||||
|
|
||||||
@settings_links = [
|
@settings_links = [
|
||||||
[ "Account", settings_profile_path ],
|
|
||||||
[ "Preferences", settings_preferences_path ],
|
|
||||||
[ "Accounts", accounts_path ],
|
[ "Accounts", accounts_path ],
|
||||||
[ "Tags", tags_path ],
|
[ "Bank Sync", settings_bank_sync_path ],
|
||||||
|
[ "Preferences", settings_preferences_path ],
|
||||||
|
[ "Profile Info", settings_profile_path ],
|
||||||
|
[ "Security", settings_security_path ],
|
||||||
[ "Categories", categories_path ],
|
[ "Categories", categories_path ],
|
||||||
|
[ "Tags", tags_path ],
|
||||||
|
[ "Rules", rules_path ],
|
||||||
[ "Merchants", family_merchants_path ],
|
[ "Merchants", family_merchants_path ],
|
||||||
[ "Imports", imports_path ],
|
[ "AI Prompts", settings_ai_prompts_path ],
|
||||||
|
[ "API Key", settings_api_key_path ],
|
||||||
|
[ "Guides", settings_guides_path ],
|
||||||
[ "What's new", changelog_path ],
|
[ "What's new", changelog_path ],
|
||||||
[ "Feedback", feedback_path ]
|
[ "Feedback", feedback_path ]
|
||||||
]
|
]
|
||||||
@@ -20,8 +25,8 @@ class SettingsTest < ApplicationSystemTestCase
|
|||||||
test "can access settings from sidebar" do
|
test "can access settings from sidebar" do
|
||||||
VCR.use_cassette("git_repository_provider/fetch_latest_release_notes") do
|
VCR.use_cassette("git_repository_provider/fetch_latest_release_notes") do
|
||||||
open_settings_from_sidebar
|
open_settings_from_sidebar
|
||||||
assert_selector "h1", text: "Account"
|
assert_selector "h1", text: "Accounts"
|
||||||
assert_current_path settings_profile_path, ignore_query: true
|
assert_current_path accounts_path, ignore_query: true
|
||||||
|
|
||||||
@settings_links.each do |name, path|
|
@settings_links.each do |name, path|
|
||||||
click_link name
|
click_link name
|
||||||
|
|||||||
Reference in New Issue
Block a user