Merge remote-tracking branch 'origin/main' into feat/goals-v2-architecture

# Conflicts:
#	app/views/categories/_form.html.erb
This commit is contained in:
Guillem Arias
2026-05-17 16:21:42 +02:00
226 changed files with 4685 additions and 903 deletions

View File

@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Check version alignment
shell: bash
@@ -64,10 +64,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install Helm
uses: azure/setup-helm@v4.3.1
uses: azure/setup-helm@v5
- name: Add chart dependencies repositories
run: |

View File

@@ -18,7 +18,7 @@ jobs:
app_version: ${{ steps.tag.outputs.app_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -79,13 +79,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download Helm chart artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: helm-chart-package
path: ${{ runner.temp }}/helm-artifacts
- name: Create chart GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.prepare_release.outputs.tag_name }}
name: ${{ needs.prepare_release.outputs.tag_name }}

View File

@@ -9,7 +9,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
@@ -25,7 +25,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
@@ -41,7 +41,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
@@ -57,12 +57,12 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js environment
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: "20"
node-version: "24"
cache: "npm"
- name: Install dependencies
@@ -104,7 +104,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
@@ -153,7 +153,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
@@ -171,7 +171,7 @@ jobs:
run: DISABLE_PARALLELIZATION=true bin/rails test:system
- name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: failure()
with:
name: screenshots

View File

@@ -21,10 +21,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Java
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
@@ -93,7 +93,7 @@ jobs:
fi
- name: Upload APK artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app-release-apk
path: |
@@ -109,7 +109,7 @@ jobs:
- name: Upload AAB artifact
if: steps.check_secrets.outputs.has_keystore == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app-release-aab
path: mobile/build/app/outputs/bundle/release/app-release.aab
@@ -122,7 +122,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Flutter
uses: subosito/flutter-action@v2
@@ -167,7 +167,7 @@ jobs:
echo "For distribution, you need to configure code signing with Apple certificates" >> build/ios-build-info.txt
- name: Upload iOS build artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: ios-build-unsigned
path: |

View File

@@ -56,7 +56,7 @@ jobs:
- name: Download Android AAB artifact
if: ${{ steps.check_prereqs.outputs.enabled == 'true' }}
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: app-release-aab
path: ${{ runner.temp }}/android-aab

View File

@@ -29,12 +29,12 @@ jobs:
app_version: ${{ steps.version.outputs.app_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Helm
uses: azure/setup-helm@v4.3.1
uses: azure/setup-helm@v5
- name: Resolve chart and app versions
id: version
@@ -88,7 +88,7 @@ jobs:
helm package charts/sure -d .cr-release-packages
- name: Upload packaged chart artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: helm-chart-package
path: .cr-release-packages/*.tgz
@@ -98,7 +98,7 @@ jobs:
- name: Checkout gh-pages
if: ${{ inputs.update_gh_pages }}
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: gh-pages
path: gh-pages

View File

@@ -28,7 +28,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Check TestFlight credentials
id: check_prereqs
@@ -293,7 +293,7 @@ jobs:
- name: Upload build artifact
if: ${{ steps.check_prereqs.outputs.enabled == 'true' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: ios-ipa-testflight
path: mobile/build/ios/ipa/*.ipa

View File

@@ -101,7 +101,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
@@ -204,7 +204,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
@@ -320,7 +320,7 @@ jobs:
echo "status=$(jq -r '.status' "$JSON_PATH")" >> "$GITHUB_OUTPUT"
- name: Upload eval artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: llm-evals-${{ steps.dataset_slug.outputs.slug }}-${{ steps.dataset_slug.outputs.model_slug }}
path: |
@@ -346,7 +346,7 @@ jobs:
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: eval-artifacts
pattern: llm-evals-*

View File

@@ -64,21 +64,21 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: ${{ github.ref }}
fetch-depth: 0
- name: Download Android APK artifact
continue-on-error: true
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: app-release-apk
path: ${{ runner.temp }}/mobile-artifacts
- name: Download iOS build artifact
continue-on-error: true
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: ios-build-unsigned
path: ${{ runner.temp }}/ios-build
@@ -170,7 +170,7 @@ jobs:
${{ runner.temp }}/release-assets/*
- name: Checkout gh-pages branch
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: gh-pages
path: gh-pages

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -112,13 +112,13 @@ jobs:
echo "Extracted version: $VERSION"
- name: Download Android APK artifact
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: app-release-apk
path: ${{ runner.temp }}/mobile-artifacts
- name: Download iOS build artifact
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: ios-build-unsigned
path: ${{ runner.temp }}/ios-build
@@ -258,7 +258,7 @@ jobs:
done
- name: Checkout gh-pages branch
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: gh-pages
path: gh-pages

View File

@@ -11,7 +11,7 @@ jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
@@ -29,3 +29,4 @@ jobs:
config/locales/views/reports/
docs/hosting/ai.md
app/models/provider/binance.rb
workers/preview/package-lock.json

216
.github/workflows/preview-cleanup.yml vendored Normal file
View File

@@ -0,0 +1,216 @@
name: Cleanup PR Previews
on:
# Run hourly to check for expired previews
schedule:
- cron: '0 * * * *'
# Immediately cleanup when PR is closed
pull_request:
types: [closed, unlabeled]
# Allow manual trigger
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to cleanup (optional, cleans all expired if empty)'
required: false
type: string
permissions:
contents: read
deployments: write
jobs:
cleanup-on-close:
name: Cleanup closed PR preview
if: github.event_name == 'pull_request' && (github.event.action == 'closed' || (github.event.action == 'unlabeled' && github.event.label.name == 'preview-cf'))
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: "24"
- name: Install Wrangler
run: npm install -g wrangler
- name: Delete preview Worker
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
WORKER_NAME="sure-preview-${{ github.event.pull_request.number }}"
echo "Deleting Worker: $WORKER_NAME"
# Delete the worker (this also stops any running containers)
wrangler delete --name "$WORKER_NAME" --force || echo "Worker may not exist"
- name: Delete GitHub Deployment
uses: actions/github-script@v7
with:
script: |
const environment = `preview-pr-${{ github.event.pull_request.number }}`;
const description = context.payload.action === 'closed'
? 'PR closed - preview deleted'
: 'preview-cf label removed - preview deleted';
try {
// Get deployments for this environment
const { data: deployments } = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: environment
});
// Mark all deployments as inactive
for (const deployment of deployments) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
state: 'inactive',
description
});
}
console.log(`Marked ${deployments.length} deployments as inactive`);
} catch (error) {
console.log('No deployments to cleanup or error:', error.message);
}
cleanup-expired:
name: Cleanup expired previews
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: "24"
- name: Install Wrangler
run: npm install -g wrangler
- name: Cleanup expired previews
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_INPUT: ${{ inputs.pr_number }}
run: |
# If specific PR number provided, only cleanup that one
if [ -n "$PR_INPUT" ]; then
if [[ "$PR_INPUT" =~ ^[1-9][0-9]*$ ]]; then
PR_NUM="$PR_INPUT"
WORKER_NAME="sure-preview-$PR_NUM"
echo "Manually deleting Worker: $WORKER_NAME"
wrangler delete --name "$WORKER_NAME" --force || echo "Worker may not exist"
# Cleanup GitHub deployment for this PR
echo "Cleaning up GitHub deployment for PR #$PR_NUM"
gh api \
-X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \
--jq '.[].id' 2>/dev/null | while read -r DEPLOY_ID; do
if [ -n "$DEPLOY_ID" ]; then
gh api \
-X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \
-f state=inactive \
-f description="Preview manually deleted" || true
fi
done || echo "No deployments to cleanup or error occurred"
else
echo "Invalid PR number input '$PR_INPUT'; skipping manual cleanup"
fi
exit 0
fi
# Get list of all preview workers
echo "Fetching list of preview workers..."
# Use Cloudflare API to list workers and read modified_on from the list response.
# The per-script endpoint returns raw script content, not JSON metadata.
WORKERS_RESPONSE=$(curl -fsS -X GET \
"https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json") || {
echo "Failed to fetch preview worker list from Cloudflare"
exit 1
}
if ! echo "$WORKERS_RESPONSE" | jq -e '.success == true and (.result | type == "array")' >/dev/null 2>&1; then
echo "Cloudflare API returned an invalid worker list response"
echo "$WORKERS_RESPONSE" | jq -c '.errors // .'
exit 1
fi
WORKERS=$(echo "$WORKERS_RESPONSE" | jq -r '
.result[]
| select(.id | startswith("sure-preview-"))
| [.id, (.modified_on // "")]
| @tsv
')
if [ -z "$WORKERS" ]; then
echo "No preview workers found"
exit 0
fi
echo "Found preview workers:"
echo "$WORKERS" | cut -f1
# Check each worker's deployment time
CUTOFF_TIME=$(date -d '24 hours ago' +%s)
while IFS=$'\t' read -r WORKER MODIFIED_ON; do
[ -n "$WORKER" ] || continue
echo "Checking $WORKER..."
if [ -z "$MODIFIED_ON" ]; then
echo "No modified_on timestamp for $WORKER; skipping"
continue
fi
if ! MODIFIED_TS=$(date -d "$MODIFIED_ON" +%s 2>/dev/null); then
echo "Invalid modified_on timestamp for $WORKER ($MODIFIED_ON); skipping"
continue
fi
if [ "$MODIFIED_TS" -lt "$CUTOFF_TIME" ]; then
echo "Worker $WORKER is older than 24 hours, deleting..."
if wrangler delete --name "$WORKER" --force; then
# Extract PR number and cleanup GitHub deployment
PR_NUM=$(echo "$WORKER" | sed 's/sure-preview-//')
if [[ "$PR_NUM" =~ ^[1-9][0-9]*$ ]]; then
echo "Cleaning up GitHub deployment for PR #$PR_NUM"
gh api \
-X GET "/repos/${{ github.repository }}/deployments?environment=preview-pr-$PR_NUM" \
--jq '.[].id' 2>/dev/null | while read -r DEPLOY_ID; do
gh api \
-X POST "/repos/${{ github.repository }}/deployments/$DEPLOY_ID/statuses" \
-f state=inactive \
-f description="Preview expired after 24 hours" || true
done || echo "No deployments to cleanup or error occurred"
else
echo "Could not extract a valid PR number from $WORKER; skipping deployment cleanup"
fi
else
echo "Failed to delete $WORKER; skipping deployment status update"
fi
else
echo "Worker $WORKER is still within 24-hour window, keeping..."
fi
done <<< "$WORKERS"
echo "Cleanup complete"

189
.github/workflows/preview-deploy.yml vendored Normal file
View File

@@ -0,0 +1,189 @@
name: Deploy PR Preview
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
paths-ignore:
- 'charts/**'
- 'docs/**'
- '*.md'
jobs:
deploy-preview:
if: contains(github.event.pull_request.labels.*.name, 'preview-cf')
name: Deploy to Cloudflare Containers
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
actions: read
contents: read
pull-requests: write
deployments: write
steps:
- name: Wait for PR CI to pass
uses: actions/github-script@v7
with:
script: |
const headSha = context.payload.pull_request.head.sha;
const timeoutMs = 10 * 60 * 1000;
const pollMs = 15 * 1000;
const startedAt = Date.now();
let lastState = 'not found';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
while (Date.now() - startedAt < timeoutMs) {
const { data } = await github.rest.actions.listWorkflowRunsForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
event: 'pull_request',
head_sha: headSha,
per_page: 20,
});
const prRun = data.workflow_runs.find((run) => run.name === 'Pull Request' && run.head_sha === headSha);
if (prRun) {
lastState = `${prRun.status}/${prRun.conclusion ?? 'pending'}`;
core.info(`Pull Request workflow ${prRun.id}: ${lastState}`);
if (prRun.status === 'completed') {
if (prRun.conclusion === 'success') {
return;
}
core.setFailed(`Pull Request workflow concluded with ${prRun.conclusion}`);
return;
}
}
await sleep(pollMs);
}
core.setFailed(`Timed out waiting for Pull Request workflow for ${headSha}. Last state: ${lastState}`);
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Install Wrangler dependencies
working-directory: workers/preview
run: npm install
- name: Configure preview files for this PR
working-directory: workers/preview
run: |
sed -i "s/\${PR_NUMBER}/${{ github.event.pull_request.number }}/g" wrangler.toml
sed -i "s/\${PR_NUMBER}/${{ github.event.pull_request.number }}/g" src/index.ts
cat wrangler.toml
- name: Create GitHub Deployment
id: deployment
uses: actions/github-script@v7
with:
script: |
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.payload.pull_request.head.sha,
environment: `preview-pr-${{ github.event.pull_request.number }}`,
auto_merge: false,
required_contexts: [],
description: 'PR Preview Deployment'
});
return deployment.data.id;
result-encoding: string
- name: Deploy to Cloudflare Containers
id: deploy
working-directory: workers/preview
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx wrangler deploy --var "PR_NUMBER:${{ github.event.pull_request.number }}"
# Get the deployment URL
PREVIEW_URL="https://sure-preview-${{ github.event.pull_request.number }}.${{ secrets.CLOUDFLARE_WORKERS_SUBDOMAIN }}.workers.dev"
echo "preview_url=${PREVIEW_URL}" >> "$GITHUB_OUTPUT"
- name: Warm preview container
env:
PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
run: |
echo "Triggering preview wake-up..."
curl -fsS "$PREVIEW_URL/" >/dev/null || true
- name: Update Deployment Status
if: always() && steps.deployment.outputs.result
uses: actions/github-script@v7
with:
script: |
const state = '${{ job.status }}' === 'success' ? 'success' : 'failure';
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: ${{ steps.deployment.outputs.result }},
state: state,
environment_url: state === 'success' ? '${{ steps.deploy.outputs.preview_url }}' : undefined,
description: state === 'success' ? 'Preview deployed successfully' : 'Preview deployment failed'
});
- name: Comment on PR
if: success()
uses: actions/github-script@v7
with:
script: |
const previewUrl = '${{ steps.deploy.outputs.preview_url }}';
const commentBody = `## 🚀 Preview Deployment Ready
Your preview environment has been deployed to Cloudflare Containers with the PR's Docker image.
**Preview URL:** ${previewUrl}
> ⏰ This preview is intended to be cleaned up after **24 hours** of the last deployment once the cleanup workflow is live on the default branch.
> 💤 The container will sleep after 30 minutes of inactivity and wake on the next request.
---
<sub>Deployed from commit ${{ github.event.pull_request.head.sha }}</sub>`;
// Find existing comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.pull_request.number }}
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('Preview Deployment Ready')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.pull_request.number }},
body: commentBody
});
}
- name: Store cleanup metadata
if: success()
uses: actions/upload-artifact@v6
with:
name: preview-cleanup-pr-${{ github.event.pull_request.number }}
path: |
workers/preview/wrangler.toml
retention-days: 2

View File

@@ -73,15 +73,15 @@ jobs:
steps:
- name: Check out the repo
uses: actions/checkout@v4.2.0
uses: actions/checkout@v5
with:
ref: ${{ github.event.inputs.ref || github.ref }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.10.0
uses: docker/setup-buildx-action@v4
- name: Log in to the container registry
uses: docker/login-action@v3.3.0
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -103,7 +103,6 @@ jobs:
BASE_CONFIG+=$'\n'"type=raw,value=latest"
else
BASE_CONFIG+=$'\n'"type=raw,value=stable"
BASE_CONFIG+=$'\n'"type=raw,value=latest"
fi
fi
fi
@@ -119,7 +118,7 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5.6.0
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: latest=false
@@ -133,7 +132,7 @@ jobs:
org.opencontainers.image.description=A multi-arch Docker image for the Sure Rails app
- name: Publish 'linux/${{ matrix.platform }}' image by digest
uses: docker/build-push-action@v6.16.0
uses: docker/build-push-action@v7
id: build
with:
context: .
@@ -159,7 +158,7 @@ jobs:
- name: Upload the Docker image digest
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'schedule' || github.event.inputs.push }}
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v6
with:
name: digest-${{ matrix.platform }}
path: ${{ runner.temp }}/digests/*
@@ -179,17 +178,17 @@ jobs:
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.10.0
uses: docker/setup-buildx-action@v4
- name: Download Docker image digests
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: Log in to the container registry
uses: docker/login-action@v3.3.0
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -276,19 +275,19 @@ jobs:
steps:
- name: Download Android APK artifact
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: app-release-apk
path: ${{ runner.temp }}/mobile-artifacts
- name: Download iOS build artifact
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: ios-build-unsigned
path: ${{ runner.temp }}/ios-build
- name: Download Helm chart artifact
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v7
with:
name: helm-chart-package
path: ${{ runner.temp }}/helm-artifacts
@@ -339,7 +338,7 @@ jobs:
ls -la "${{ runner.temp }}/release-assets/"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
@@ -426,10 +425,10 @@ jobs:
echo "branch=$SOURCE_BRANCH" >> $GITHUB_OUTPUT
- name: Check out source branch
uses: actions/checkout@v4.2.0
uses: actions/checkout@v5
with:
ref: ${{ steps.source_branch.outputs.branch }}
token: ${{ secrets.GH_PAT || github.token }}
token: ${{ github.token }}
- name: Bump pre-release version
run: |

View File

@@ -1 +1 @@
0.7.1-alpha.7
0.7.1-alpha.8

247
Dockerfile.preview Normal file
View File

@@ -0,0 +1,247 @@
# syntax = docker/dockerfile:1
# Preview Dockerfile for Cloudflare Containers
# Includes PostgreSQL and Redis for self-contained development testing
ARG RUBY_VERSION=3.4.7
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
WORKDIR /rails
# Install base packages including PostgreSQL and Redis servers
RUN apt-get update -qq \
&& apt-get install --no-install-recommends -y \
curl libvips postgresql postgresql-client redis-server libyaml-0-2 procps sudo openssl strace \
&& rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set development environment
ARG BUILD_COMMIT_SHA
ENV RAILS_ENV="development" \
BUNDLE_PATH="/usr/local/bundle" \
BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA}
# Build stage
FROM base AS build
RUN apt-get update -qq \
&& apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config libyaml-dev \
&& rm -rf /var/lib/apt/lists /var/cache/apt/archives
COPY .ruby-version Gemfile Gemfile.lock ./
RUN bundle install \
&& rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git \
&& bundle exec bootsnap precompile --gemfile -j 0
COPY . .
RUN bundle exec bootsnap precompile -j 0 app/ lib/
# Precompile assets
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage
FROM base
# Create rails user and configure PostgreSQL/Redis permissions
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
echo "rails ALL=(ALL) NOPASSWD: /usr/bin/pg_ctlcluster, /usr/bin/redis-server" > /etc/sudoers.d/rails && \
chmod 0440 /etc/sudoers.d/rails
# Configure PostgreSQL to allow local connections
RUN PG_HBA=$(find /etc/postgresql -name pg_hba.conf 2>/dev/null | head -1) && \
if [ -n "$PG_HBA" ]; then \
echo "local all all trust" > "$PG_HBA" && \
echo "host all all 127.0.0.1/32 trust" >> "$PG_HBA" && \
echo "host all all ::1/128 trust" >> "$PG_HBA"; \
fi
# Create database directory with correct permissions
RUN mkdir -p /var/run/postgresql && \
chown -R postgres:postgres /var/run/postgresql && \
chmod 2775 /var/run/postgresql
# Copy built artifacts
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
# Create preview entrypoint script inline
RUN cat > /rails/bin/preview-entrypoint << 'ENTRYPOINT_EOF'
#!/bin/bash
set -e
cd /rails
emit_status() {
if [ -n "$PREVIEW_ORIGIN" ]; then
local stage="$1"
local detail="$2"
local payload
payload=$(STAGE="$stage" DETAIL="$detail" ruby -rjson -e 'print JSON.generate({stage: ENV.fetch("STAGE"), detail: ENV.fetch("DETAIL", "")})' 2>/dev/null) || return 0
curl -fsS -X POST "$PREVIEW_ORIGIN/_container_event" \
-H 'content-type: application/json' \
--data "$payload" >/dev/null || true
fi
}
trap 'emit_status failed "preview-entrypoint failed on line ${LINENO}"' ERR
emit_status boot "preview-entrypoint started"
REDIS_READY=0
POSTGRES_READY=0
# Start Redis
echo "Starting Redis..."
emit_status redis-start "starting redis"
sudo redis-server --daemonize yes --bind 127.0.0.1
# Wait for Redis to be ready
echo "Waiting for Redis to be ready..."
for i in {1..10}; do
if redis-cli ping > /dev/null 2>&1; then
echo "Redis is ready"
emit_status redis-ready "redis is ready"
REDIS_READY=1
break
fi
sleep 1
done
if [ "$REDIS_READY" -ne 1 ]; then
echo "Redis did not become ready in time"
exit 1
fi
# Start PostgreSQL
echo "Starting PostgreSQL..."
emit_status postgres-start "starting postgres"
PG_VERSION=$(ls /etc/postgresql/ | sort -V | tail -1)
if [ -z "$PG_VERSION" ]; then
echo "Could not determine installed PostgreSQL version"
exit 1
fi
if sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main status > /dev/null 2>&1; then
emit_status postgres-already-running "postgres cluster already running"
else
sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main start
fi
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL to be ready..."
for i in {1..30}; do
if pg_isready -h localhost -U postgres > /dev/null 2>&1; then
echo "PostgreSQL is ready"
emit_status postgres-ready "postgres is ready"
POSTGRES_READY=1
break
fi
sleep 1
done
if [ "$POSTGRES_READY" -ne 1 ]; then
echo "PostgreSQL did not become ready in time"
exit 1
fi
# Create database user and database if they don't exist
echo "Setting up database..."
emit_status db-setup "setting up database"
psql -h localhost -U postgres -tc "SELECT 1 FROM pg_roles WHERE rolname='rails'" | grep -q 1 || \
psql -h localhost -U postgres -c "CREATE USER rails WITH SUPERUSER PASSWORD 'rails';"
psql -h localhost -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='sure_development'" | grep -q 1 || \
psql -h localhost -U postgres -c "CREATE DATABASE sure_development OWNER rails;"
# Set DATABASE_URL if not already set
export DATABASE_URL="${DATABASE_URL:-postgres://rails:rails@localhost:5432/sure_development}"
# Set REDIS_URL if not already set
export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}"
# Generate SECRET_KEY_BASE if not set
export SECRET_KEY_BASE="${SECRET_KEY_BASE:-$(openssl rand -hex 64)}"
# Run database migrations
echo "Running database migrations..."
emit_status db-prepare "running rails db:prepare"
/rails/bin/rails db:prepare
emit_status db-prepare-done "rails db:prepare finished"
# Defer all demo-data creation until after Rails is up so preview can boot first
echo "Checking demo dataset..."
emit_status demo-data-check "checking for default demo user"
DEMO_EMAIL="${DEMO_USER_EMAIL:-user@example.com}"
DEMO_EMAIL_SQL=${DEMO_EMAIL//\'/\'\'}
DEMO_SEED="${DEMO_DATA_SEED:-880}"
DEMO_HAS_USER=0
DEMO_HAS_DATA=0
if psql "$DATABASE_URL" -tAc "SELECT 1 FROM users WHERE email = '${DEMO_EMAIL_SQL}' LIMIT 1" | grep -q 1; then
DEMO_HAS_USER=1
emit_status demo-data-user-present "default demo user already exists"
fi
if psql "$DATABASE_URL" -tAc "SELECT 1 FROM accounts a JOIN users u ON u.family_id = a.family_id WHERE u.email = '${DEMO_EMAIL_SQL}' LIMIT 1" | grep -q 1; then
DEMO_HAS_DATA=1
emit_status demo-data-skip "demo financial data already exists"
else
emit_status demo-data-deferred "deferring demo data creation until after rails boot"
fi
# Execute the main command with an internal readiness probe
echo "Starting Rails server..."
emit_status rails-start "starting rails server"
"$@" > /tmp/rails.log 2>&1 &
RAILS_PID=$!
for i in {1..180}; do
if curl -fsS http://127.0.0.1:3000/up > /dev/null 2>&1; then
emit_status rails-up-ready "rails responded on localhost:3000/up"
if [ "$DEMO_HAS_USER" -ne 1 ] || [ "$DEMO_HAS_DATA" -ne 1 ]; then
emit_status demo-data-load "creating/backfilling demo dataset in background (seed=${DEMO_SEED})"
(
(
DEMO_USER_EMAIL="$DEMO_EMAIL" DEMO_DATA_SEED="$DEMO_SEED" /rails/bin/rails runner '
email = ENV.fetch("DEMO_USER_EMAIL")
generator = Demo::Generator.new(seed: ENV.fetch("DEMO_DATA_SEED"))
user = User.find_by(email: email)
unless user
generator.generate_empty_data!(skip_clear: true)
user = User.find_by!(email: email)
end
has_accounts = user.family.accounts.exists?
generator.generate_new_user_data_for!(user.family, email: user.email) unless has_accounts
'
) > /tmp/demo-data.log 2>&1 && \
emit_status demo-data-ready "default demo dataset loaded in background" || \
emit_status demo-data-failed "background demo dataset load failed"
) &
fi
break
fi
sleep 1
done
if ! curl -fsS http://127.0.0.1:3000/up > /dev/null 2>&1; then
emit_status rails-up-timeout "rails did not answer localhost:3000/up in time"
emit_status rails-process-status "$(ps -o pid=,ppid=,stat=,comm=,args= -p "$RAILS_PID" 2>/dev/null | tr -s ' ' | sed 's/^ //')"
emit_status rails-process-wchan "$(cat /proc/$RAILS_PID/wchan 2>/dev/null | tr '\n' ' ' | cut -c 1-200)"
emit_status rails-process-children "$(ps -o pid=,ppid=,stat=,comm=,args= --ppid "$RAILS_PID" 2>/dev/null | tail -n +2 | tr '\n' '|' | cut -c 1-600)"
emit_status rails-socket-state "$(ruby -e 'hex="0BB8"; rows=File.readlines("/proc/net/tcp")+File.readlines("/proc/net/tcp6"); hits=rows.select{|l| l.include?(":#{hex} ")}.map{|l| l.strip.split[3] rescue nil}.compact; puts(hits.empty? ? "no-listener" : hits.join(","))' 2>&1 | tr '\n' ' ' | cut -c 1-400)"
emit_status rails-log-tail "$(tail -n 40 /tmp/rails.log 2>&1 | sed 's/"/'"'"'/g' | tr '\n' ' ' | cut -c 1-1200)"
fi
wait "$RAILS_PID"
ENTRYPOINT_EOF
RUN chmod 755 /rails/bin/preview-entrypoint && chown rails:rails /rails/bin/preview-entrypoint
USER 1000:1000
ENTRYPOINT ["/rails/bin/preview-entrypoint"]
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

View File

@@ -21,7 +21,7 @@
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="font-medium privacy-sensitive"><%= end_balance_money.format %></span>
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
<%= render DS::Tooltip.new(text: t(".balance_tooltip"), placement: "left", size: "sm") %>
</div>
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
</div>
@@ -32,7 +32,7 @@
<% if balance %>
<%= render UI::Account::BalanceReconciliation.new(balance: balance, account: account) %>
<% else %>
<p class="text-sm text-secondary">No balance data available for this date</p>
<p class="text-sm text-secondary"><%= t(".no_balance_data") %></p>
<% end %>
</div>
</details>

View File

@@ -50,7 +50,7 @@
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm">No data available</p>
<p class="text-secondary text-sm"><%= t(".no_data_available") %></p>
</div>
<% end %>
</div>

View File

@@ -58,7 +58,7 @@ class CategoriesController < ApplicationController
def destroy_all
Current.family.categories.destroy_all
redirect_back_or_to categories_path, notice: "All categories deleted"
redirect_back_or_to categories_path, notice: t(".success")
end
def bootstrap

View File

@@ -29,7 +29,7 @@ class ChatsController < ApplicationController
@chat.update!(chat_params)
respond_to do |format|
format.html { redirect_back_or_to chat_path(@chat), notice: "Chat updated" }
format.html { redirect_back_or_to chat_path(@chat), notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@chat, :title), partial: "chats/chat_title", locals: { chat: @chat }) }
end
end
@@ -38,7 +38,7 @@ class ChatsController < ApplicationController
@chat.destroy
clear_last_viewed_chat
redirect_to chats_path, notice: "Chat was successfully deleted"
redirect_to chats_path, notice: t(".notice")
end
def retry

View File

@@ -23,7 +23,7 @@ module SelfHostable
if controller_name == "pages" && action_name == "redis_configuration_error"
# If Redis is now working, redirect to home
if redis_connected?
redirect_to root_path, notice: "Redis is now configured properly! You can now setup your Sure application."
redirect_to root_path, notice: t("concerns.self_hostable.redis_configured")
end
return

View File

@@ -42,7 +42,7 @@ class HoldingsController < ApplicationController
@holding.destroy_holding_and_entries!
flash[:notice] = t(".success")
else
flash[:alert] = "You cannot delete this holding"
flash[:alert] = t(".cannot_delete")
end
respond_to do |format|

View File

@@ -6,7 +6,7 @@ class Import::CleansController < ApplicationController
def show
unless @import.configured?
redirect_path = @import.is_a?(PdfImport) ? import_path(@import) : import_configuration_path(@import)
return redirect_to redirect_path, alert: "Please configure your import before proceeding."
return redirect_to redirect_path, alert: t(".not_configured")
end
rows = @import.rows_ordered

View File

@@ -8,7 +8,7 @@ class Import::ConfirmsController < ApplicationController
return redirect_to import_path(@import)
end
redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned?
redirect_to import_clean_path(@import), alert: t(".invalid_data") unless @import.cleaned?
end
private

View File

@@ -54,7 +54,7 @@ class Import::QifCategorySelectionsController < ApplicationController
@import.sync_mappings unless format_changed
end
redirect_to import_clean_path(@import), notice: "Categories and tags saved."
redirect_to import_clean_path(@import), notice: t(".success")
end
private

View File

@@ -85,7 +85,7 @@ class Import::UploadsController < ApplicationController
@import.sync_mappings
end
redirect_to import_qif_category_selection_path(@import), notice: "QIF file uploaded successfully."
redirect_to import_qif_category_selection_path(@import), notice: t(".qif_uploaded")
end
def csv_str

View File

@@ -22,9 +22,9 @@ class ImportsController < ApplicationController
def publish
@import.publish_later
redirect_to import_path(@import), notice: "Your import has started in the background."
redirect_to import_path(@import), notice: t(".started")
rescue Import::MaxRowCountExceededError
redirect_back_or_to import_path(@import), alert: "Your import exceeds the maximum row count of #{@import.max_row_count}."
redirect_back_or_to import_path(@import), alert: t(".max_rows_exceeded", max: @import.max_row_count)
end
def index
@@ -112,22 +112,22 @@ class ImportsController < ApplicationController
def revert
@import.revert_later
redirect_to imports_path, notice: "Import is reverting in the background."
redirect_to imports_path, notice: t(".started")
end
def apply_template
if @import.suggested_template
@import.apply_template!(@import.suggested_template)
redirect_to import_configuration_path(@import), notice: "Template applied."
redirect_to import_configuration_path(@import), notice: t(".template_applied")
else
redirect_to import_configuration_path(@import), alert: "No template found, please manually configure your import."
redirect_to import_configuration_path(@import), alert: t(".no_template_found")
end
end
def destroy
@import.destroy
redirect_to imports_path, notice: "Your import has been deleted."
redirect_to imports_path, notice: t(".deleted")
end
private

View File

@@ -8,13 +8,13 @@ class InviteCodesController < ApplicationController
def create
InviteCode.generate!
redirect_back_or_to invite_codes_path, notice: "Code generated"
redirect_back_or_to invite_codes_path, notice: t(".success")
end
def destroy
code = InviteCode.find(params[:id])
code.destroy
redirect_back_or_to invite_codes_path, notice: "Code deleted"
redirect_back_or_to invite_codes_path, notice: t(".success")
end
private

View File

@@ -7,7 +7,7 @@ class OidcAccountsController < ApplicationController
@pending_auth = session[:pending_oidc_auth]
if @pending_auth.nil?
redirect_to new_session_path, alert: "No pending OIDC authentication found"
redirect_to new_session_path, alert: t(".no_pending_oidc")
return
end
@@ -26,7 +26,7 @@ class OidcAccountsController < ApplicationController
@pending_auth = session[:pending_oidc_auth]
if @pending_auth.nil?
redirect_to new_session_path, alert: "No pending OIDC authentication found"
redirect_to new_session_path, alert: t(".no_pending_oidc")
return
end
@@ -75,7 +75,7 @@ class OidcAccountsController < ApplicationController
@pending_auth = session[:pending_oidc_auth]
if @pending_auth.nil?
redirect_to new_session_path, alert: "No pending OIDC authentication found"
redirect_to new_session_path, alert: t(".no_pending_oidc")
return
end
@@ -91,7 +91,7 @@ class OidcAccountsController < ApplicationController
@pending_auth = session[:pending_oidc_auth]
if @pending_auth.nil?
redirect_to new_session_path, alert: "No pending OIDC authentication found"
redirect_to new_session_path, alert: t(".no_pending_oidc")
return
end
@@ -104,7 +104,7 @@ class OidcAccountsController < ApplicationController
# domain is not allowed, block JIT account creation—unless there's a
# pending invitation for this user.
unless invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email))
redirect_to new_session_path, alert: "SSO account creation is disabled. Please contact an administrator."
redirect_to new_session_path, alert: t(".account_creation_disabled")
return
end
@@ -164,7 +164,7 @@ class OidcAccountsController < ApplicationController
elsif accept_pending_invitation_for(@user)
t("invitations.accept_choice.joined_household")
else
"Welcome! Your account has been created."
t(".account_created")
end
redirect_to root_path, notice: notice
else

View File

@@ -21,7 +21,7 @@ class PendingDuplicateMergesController < ApplicationController
# Manually merge the pending transaction with the selected posted transaction
unless merge_params[:posted_entry_id].present?
redirect_back_or_to transactions_path, alert: "Please select a posted transaction to merge with"
redirect_back_or_to transactions_path, alert: t(".no_posted_selected")
return
end
@@ -29,7 +29,7 @@ class PendingDuplicateMergesController < ApplicationController
posted_entry = find_eligible_posted_entry(merge_params[:posted_entry_id])
unless posted_entry
redirect_back_or_to transactions_path, alert: "Invalid transaction selected for merge"
redirect_back_or_to transactions_path, alert: t(".invalid_transaction")
return
end
@@ -48,9 +48,9 @@ class PendingDuplicateMergesController < ApplicationController
# Immediately merge
if @transaction.merge_with_duplicate!
redirect_back_or_to transactions_path, notice: "Pending transaction merged with posted transaction"
redirect_back_or_to transactions_path, notice: t(".merge_success")
else
redirect_back_or_to transactions_path, alert: "Could not merge transactions"
redirect_back_or_to transactions_path, alert: t(".merge_failed")
end
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed,
ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout => e
@@ -64,7 +64,7 @@ class PendingDuplicateMergesController < ApplicationController
@transaction = entry.entryable
unless @transaction.is_a?(Transaction) && @transaction.pending?
redirect_to transactions_path, alert: "This feature is only available for pending transactions"
redirect_to transactions_path, alert: t("pending_duplicate_merges.set_transaction.pending_only")
end
end

View File

@@ -62,7 +62,7 @@ class PlaidItemsController < ApplicationController
.select { |pa| pa.account_provider.nil? && pa.account.nil? } # Not linked via new or legacy system
if @available_plaid_accounts.empty?
redirect_to account_path(@account), alert: "No available Plaid accounts to link. Please connect a new Plaid account first."
redirect_to account_path(@account), alert: t(".no_available_accounts")
end
end
@@ -72,13 +72,13 @@ class PlaidItemsController < ApplicationController
# Verify the Plaid account belongs to this family's Plaid items
unless Current.family.plaid_items.include?(plaid_account.plaid_item)
redirect_to account_path(@account), alert: "Invalid Plaid account selected"
redirect_to account_path(@account), alert: t(".invalid_account")
return
end
# Verify the Plaid account is not already linked
if plaid_account.account_provider.present? || plaid_account.account.present?
redirect_to account_path(@account), alert: "This Plaid account is already linked"
redirect_to account_path(@account), alert: t(".already_linked")
return
end
@@ -88,7 +88,7 @@ class PlaidItemsController < ApplicationController
provider: plaid_account
)
redirect_to accounts_path, notice: "Account successfully linked to Plaid"
redirect_to accounts_path, notice: t(".success")
end
private

View File

@@ -86,8 +86,8 @@ class RulesController < ApplicationController
def update
if @rule.update(rule_params)
respond_to do |format|
format.html { redirect_back_or_to rules_path, notice: "Rule updated" }
format.turbo_stream { stream_redirect_back_or_to rules_path, notice: "Rule updated" }
format.html { redirect_back_or_to rules_path, notice: t(".success") }
format.turbo_stream { stream_redirect_back_or_to rules_path, notice: t(".success") }
end
else
render :edit, status: :unprocessable_entity
@@ -96,12 +96,12 @@ class RulesController < ApplicationController
def destroy
@rule.destroy
redirect_to rules_path, notice: "Rule deleted"
redirect_to rules_path, notice: t(".success")
end
def destroy_all
Current.family.rules.destroy_all
redirect_to rules_path, notice: "All rules deleted"
redirect_to rules_path, notice: t(".success")
end
def confirm_all

View File

@@ -31,7 +31,7 @@ class Settings::ApiKeysController < ApplicationController
existing_keys.each { |key| key.update_column(:revoked_at, Time.current) }
if @api_key.save
flash[:notice] = "Your API key has been created successfully"
flash[:notice] = t(".success")
redirect_to settings_api_key_path
else
# Restore existing keys if new key creation failed
@@ -42,13 +42,13 @@ class Settings::ApiKeysController < ApplicationController
def destroy
if @api_key.nil?
flash[:alert] = "API key not found"
flash[:alert] = t(".not_found")
elsif @api_key.demo_monitoring_key?
flash[:alert] = "This API key cannot be revoked"
flash[:alert] = t(".cannot_revoke")
elsif @api_key.revoke!
flash[:notice] = "API key has been revoked successfully"
flash[:notice] = t(".revoked_successfully")
else
flash[:alert] = "Failed to revoke API key"
flash[:alert] = t(".revoke_failed")
end
redirect_to settings_api_key_path
end

View File

@@ -29,9 +29,9 @@ class Settings::ProfilesController < ApplicationController
if @user.destroy
# Also destroy the invitation associated with this user for this family
Current.family.invitations.find_by(email: @user.email)&.destroy
flash[:notice] = "Member removed successfully."
flash[:notice] = t(".member_removed")
else
flash[:alert] = "Failed to remove member."
flash[:alert] = t(".member_removal_failed")
end
redirect_to settings_profile_path

View File

@@ -66,9 +66,9 @@ class Settings::ProvidersController < ApplicationController
# Reload provider configurations if needed
reload_provider_configs(updated_fields)
redirect_to settings_providers_path, notice: "Provider settings updated successfully"
redirect_to settings_providers_path, notice: t(".updated_successfully")
else
redirect_to settings_providers_path, notice: "No changes were made"
redirect_to settings_providers_path, notice: t(".no_changes")
end
rescue => error
Rails.logger.error("Failed to update provider settings: #{error.class} - #{error.message}")

View File

@@ -380,7 +380,7 @@ class SnaptradeItemsController < ApplicationController
end
def link_accounts
redirect_to settings_providers_path, alert: "Use the account setup flow instead"
redirect_to settings_providers_path, alert: t(".use_setup_flow")
end
def select_existing_account

View File

@@ -8,7 +8,7 @@ class SubscriptionsController < ApplicationController
# Upgrade page for unsubscribed users
def upgrade
if Current.family.subscription&.active?
redirect_to root_path, notice: "You are already contributing. Thank you!"
redirect_to root_path, notice: t(".already_contributing")
else
@plan = params[:plan] || "annual"
render layout: "onboardings"
@@ -33,9 +33,9 @@ class SubscriptionsController < ApplicationController
def create
if Current.family.can_start_trial?
Current.family.start_trial_subscription!
redirect_to root_path, notice: "Welcome to Sure!"
redirect_to root_path, notice: t(".welcome")
else
redirect_to root_path, alert: "You have already started or completed a trial. Please upgrade to continue."
redirect_to root_path, alert: t(".trial_already_used")
end
end
@@ -54,9 +54,9 @@ class SubscriptionsController < ApplicationController
if checkout_result.success?
Current.family.start_subscription!(checkout_result.subscription_id)
redirect_to root_path, notice: "Welcome to Sure! Your contribution is appreciated."
redirect_to root_path, notice: t(".welcome_with_contribution")
else
redirect_to root_path, alert: "Something went wrong processing your contribution. Please try again."
redirect_to root_path, alert: t(".contribution_failed")
end
end

View File

@@ -36,7 +36,7 @@ class TagsController < ApplicationController
def destroy_all
Current.family.tags.destroy_all
redirect_back_or_to tags_path, notice: "All tags deleted"
redirect_back_or_to tags_path, notice: t(".all_deleted")
end
private

View File

@@ -107,7 +107,7 @@ class TransactionsController < ApplicationController
@entry.mark_user_modified!
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
flash[:notice] = "Transaction created"
flash[:notice] = t(".created")
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account) }
@@ -141,7 +141,7 @@ class TransactionsController < ApplicationController
@entry.reload
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" }
format.html { redirect_back_or_to account_path(@entry.account), notice: t(".updated") }
format.turbo_stream do
in_split_group = helpers.in_split_group?(@entry, params[:grouped])
render turbo_stream: [

View File

@@ -32,7 +32,7 @@ class TransferMatchesController < ApplicationController
@transfer.sync_account_later
redirect_back_or_to transactions_path, notice: "Transfer created"
redirect_back_or_to transactions_path, notice: t(".success")
end
private

View File

@@ -6,7 +6,7 @@ class UsersController < ApplicationController
if @user.resend_confirmation_email
redirect_to settings_profile_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: t("no_pending_change")
redirect_to settings_profile_path, alert: t(".no_pending_change")
end
end

View File

@@ -57,8 +57,8 @@ class ValuationsController < ApplicationController
if result.success?
respond_to do |format|
format.html { redirect_back_or_to account_path(account), notice: "Account updated" }
format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: "Account updated") }
format.html { redirect_back_or_to account_path(account), notice: t(".account_updated") }
format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: t(".account_updated")) }
end
else
@error_message = result.error_message
@@ -84,7 +84,7 @@ class ValuationsController < ApplicationController
@entry.reload
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: "Entry updated" }
format.html { redirect_back_or_to account_path(@entry.account), notice: t(".entry_updated") }
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace(

View File

@@ -74,7 +74,7 @@ module ApplicationHelper
def family_moniker
Current.family&.moniker_label || "Family"
Current.family&.moniker_label || I18n.t("shared.family_moniker.singular")
end
def family_moniker_downcase
@@ -82,7 +82,7 @@ module ApplicationHelper
end
def family_moniker_plural
Current.family&.moniker_label_plural || "Families"
Current.family&.moniker_label_plural || I18n.t("shared.family_moniker.plural")
end
def family_moniker_plural_downcase

View File

@@ -1,21 +1,21 @@
module CategoriesHelper
def transfer_category
Category.new \
name: "Transfer",
name: I18n.t("categories.virtual.transfer"),
color: Category::TRANSFER_COLOR,
lucide_icon: "arrow-right-left"
end
def payment_category
Category.new \
name: "Payment",
name: I18n.t("categories.virtual.payment"),
color: Category::PAYMENT_COLOR,
lucide_icon: "arrow-right"
end
def trade_category
Category.new \
name: "Trade",
name: I18n.t("categories.virtual.trade"),
color: Category::TRADE_COLOR
end

View File

@@ -38,14 +38,14 @@ class CustomConfirm
end
def default_title
"Are you sure?"
I18n.t("shared.custom_confirm.default_title")
end
def default_body
"This is not reversible."
I18n.t("shared.custom_confirm.default_body")
end
def default_btn_text
"Confirm"
I18n.t("shared.custom_confirm.default_btn_text")
end
end

View File

@@ -1,31 +1,31 @@
module ImportsHelper
def mapping_label(mapping_class)
{
"Import::AccountTypeMapping" => "Account Type",
"Import::AccountMapping" => "Account",
"Import::CategoryMapping" => "Category",
"Import::TagMapping" => "Tag"
"Import::AccountTypeMapping" => I18n.t("imports.mapping_labels.account_type"),
"Import::AccountMapping" => I18n.t("imports.mapping_labels.account"),
"Import::CategoryMapping" => I18n.t("imports.mapping_labels.category"),
"Import::TagMapping" => I18n.t("imports.mapping_labels.tag")
}.fetch(mapping_class.name)
end
def import_col_label(key)
{
date: "Date",
amount: "Amount",
name: "Name",
currency: "Currency",
category: "Category",
tags: "Tags",
account: "Account",
notes: "Notes",
qty: "Quantity",
ticker: "Ticker",
exchange: "Exchange",
price: "Price",
entity_type: "Type",
category_parent: "Parent category",
category_color: "Color",
category_icon: "Lucide icon"
date: I18n.t("imports.column_labels.date"),
amount: I18n.t("imports.column_labels.amount"),
name: I18n.t("imports.column_labels.name"),
currency: I18n.t("imports.column_labels.currency"),
category: I18n.t("imports.column_labels.category"),
tags: I18n.t("imports.column_labels.tags"),
account: I18n.t("imports.column_labels.account"),
notes: I18n.t("imports.column_labels.notes"),
qty: I18n.t("imports.column_labels.qty"),
ticker: I18n.t("imports.column_labels.ticker"),
exchange: I18n.t("imports.column_labels.exchange"),
price: I18n.t("imports.column_labels.price"),
entity_type: I18n.t("imports.column_labels.entity_type"),
category_parent: I18n.t("imports.column_labels.category_parent"),
category_color: I18n.t("imports.column_labels.category_color"),
category_icon: I18n.t("imports.column_labels.category_icon")
}[key]
end

View File

@@ -112,7 +112,7 @@ class ApiKey < ApplicationRecord
def prevent_demo_monitoring_key_destroy!
return unless demo_monitoring_key?
errors.add(:base, "Cannot destroy demo monitoring API key")
errors.add(:base, :cannot_destroy_demo_key)
throw(:abort)
end
end

View File

@@ -17,7 +17,7 @@ class CategoryImport < Import
parent = ensure_placeholder_category(row.category_parent)
if parent && parent == category
errors.add(:base, "Category '#{category.name}' cannot be its own parent")
errors.add(:base, :own_parent, name: category.name)
raise ActiveRecord::RecordInvalid.new(self)
end
@@ -82,7 +82,7 @@ class CategoryImport < Import
missing_headers = required_column_keys.map(&:to_s).reject { |key| header_for(key).present? }
return if missing_headers.empty?
errors.add(:base, "Missing required columns: #{missing_headers.join(', ')}")
errors.add(:base, :missing_columns, columns: missing_headers.join(", "))
raise ActiveRecord::RecordInvalid.new(self)
end

View File

@@ -418,7 +418,7 @@ class Import < ApplicationRecord
end
if duplicate_headers.any?
errors.add(:base, "CSV headers normalize to duplicate columns: #{duplicate_headers.map { |headers| headers.join(', ') }.join('; ')}")
errors.add(:base, :duplicate_headers, columns: duplicate_headers.map { |headers| headers.join(", ") }.join("; "))
raise ActiveRecord::RecordInvalid, self
end

View File

@@ -176,6 +176,6 @@ class IndexaCapitalItem < ApplicationRecord
def credentials_present_on_create
return if credentials_configured?
errors.add(:base, "Either INDEXA_API_TOKEN env var or username/document/password credentials are required")
errors.add(:base, :credentials_required)
end
end

View File

@@ -70,6 +70,6 @@ class PlaidAccount < ApplicationRecord
# Plaid guarantees at least one of these. This validation is a sanity check for that guarantee.
def has_balance
return if current_balance.present? || available_balance.present?
errors.add(:base, "Plaid account must have either current or available balance")
errors.add(:base, :no_balance)
end
end

View File

@@ -160,7 +160,7 @@ class Provider::EnableBanking
# @param psu_headers [Hash] Optional PSU context headers required by some ASPSPs
# @return [Hash] Transactions and continuation_key for pagination
def get_account_transactions(account_id:, date_from: nil, date_to: nil,
continuation_key: nil, transaction_status: nil, psu_headers: {})
continuation_key: nil, transaction_status: nil, psu_headers: {}, retried_date_from: false)
encoded_id = CGI.escape(account_id.to_s)
query_params = {}
query_params[:transaction_status] = transaction_status if transaction_status.present?
@@ -175,6 +175,22 @@ class Provider::EnableBanking
)
handle_response(response)
rescue EnableBankingError => e
corrected_date_from = e.corrected_date_from
if !retried_date_from && e.wrong_transactions_period? && corrected_date_from.present? && corrected_date_from != date_from
get_account_transactions(
account_id: account_id,
date_from: corrected_date_from,
date_to: date_to,
continuation_key: continuation_key,
transaction_status: transaction_status,
psu_headers: psu_headers,
retried_date_from: true
)
else
raise
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
raise EnableBankingError.new("Exception during GET request: #{e.message}", :request_failed)
end
@@ -237,7 +253,8 @@ class Provider::EnableBanking
when 408
raise EnableBankingError.new("Request timeout from Enable Banking API", :timeout)
when 422
raise EnableBankingError.new("Validation error from Enable Banking API: #{response.body}", :validation_error)
response_data = parse_response_body(response)
raise EnableBankingError.new("Validation error from Enable Banking API: #{response.body}", :validation_error, response_data: response_data)
when 429
raise EnableBankingError.new("Rate limit exceeded. Please try again later.", :rate_limited)
else
@@ -255,11 +272,28 @@ class Provider::EnableBanking
end
class EnableBankingError < StandardError
attr_reader :error_type
attr_reader :error_type, :response_data
def initialize(message, error_type = :unknown)
def initialize(message, error_type = :unknown, response_data: nil)
super(message)
@error_type = error_type
@response_data = response_data
end
def wrong_transactions_period?
error_type == :validation_error && response_data.is_a?(Hash) && response_data[:error] == "WRONG_TRANSACTIONS_PERIOD"
end
def corrected_date_from
value = response_data&.dig(:detail, :date_from)
if value.is_a?(Date)
value
elsif value.present?
Date.iso8601(value)
end
rescue ArgumentError
nil
end
end
end

View File

@@ -24,7 +24,7 @@ class RecurringTransaction < ApplicationRecord
def merchant_or_name_present
if merchant_id.blank? && name.blank?
errors.add(:base, "Either merchant or name must be present")
errors.add(:base, :merchant_or_name_required)
end
end

View File

@@ -140,14 +140,14 @@ class Rule < ApplicationRecord
return if new_record? && !actions.empty?
if actions.reject(&:marked_for_destruction?).empty?
errors.add(:base, "must have at least one action")
errors.add(:base, :min_actions)
end
end
def no_duplicate_actions
action_types = actions.reject(&:marked_for_destruction?).map(&:action_type)
errors.add(:base, "Rule cannot have duplicate actions #{action_types.inspect}") if action_types.uniq.count != action_types.count
errors.add(:base, :duplicate_actions, types: action_types.inspect) if action_types.uniq.count != action_types.count
end
# Validation: To keep rules simple and easy to understand, we don't allow nested compound conditions.
@@ -157,7 +157,7 @@ class Rule < ApplicationRecord
conditions.each do |condition|
if condition.compound?
if condition.sub_conditions.any? { |sub_condition| sub_condition.compound? }
errors.add(:base, "Compound conditions cannot be nested")
errors.add(:base, :nested_conditions)
end
end
end

View File

@@ -115,7 +115,7 @@ class RuleImport < Import
# Validate resource type
unless resource_type == "transaction"
errors.add(:base, "Unsupported resource type: #{resource_type}")
errors.add(:base, :unsupported_resource_type, resource_type: resource_type)
raise ActiveRecord::RecordInvalid.new(self)
end
@@ -124,13 +124,13 @@ class RuleImport < Import
conditions_data = parse_json_safely(row.conditions, "conditions")
actions_data = parse_json_safely(row.actions, "actions")
rescue JSON::ParserError => e
errors.add(:base, "Invalid JSON in conditions or actions: #{e.message}")
errors.add(:base, :invalid_json, message: e.message)
raise ActiveRecord::RecordInvalid.new(self)
end
# Validate we have at least one action
if actions_data.empty?
errors.add(:base, "Rule must have at least one action")
errors.add(:base, :min_actions)
raise ActiveRecord::RecordInvalid.new(self)
end

View File

@@ -137,6 +137,6 @@ class SimplefinAccount < ApplicationRecord
end
def has_balance
return if current_balance.present? || available_balance.present?
errors.add(:base, "SimpleFin account must have either current or available balance")
errors.add(:base, :no_balance)
end
end

View File

@@ -158,7 +158,7 @@ class SophtronAccount < ApplicationRecord
end
def has_balance
return if balance.present? || available_balance.present?
errors.add(:base, "Sophtron account must have either current or available balance")
errors.add(:base, :no_balance)
end
def first_present(hash, *keys)

View File

@@ -90,7 +90,7 @@ class SsoProvider < ApplicationRecord
idp_sso_url = settings&.dig("idp_sso_url")
if idp_metadata_url.blank? && idp_sso_url.blank?
errors.add(:settings, "Either IdP Metadata URL or IdP SSO URL is required for SAML providers")
errors.add(:settings, :saml_url_required)
end
# If using manual config, require certificate
@@ -99,17 +99,17 @@ class SsoProvider < ApplicationRecord
idp_fingerprint = settings&.dig("idp_cert_fingerprint")
if idp_cert.blank? && idp_fingerprint.blank?
errors.add(:settings, "Either IdP Certificate or Certificate Fingerprint is required when not using metadata URL")
errors.add(:settings, :saml_cert_required)
end
end
# Validate URL formats if provided
if idp_metadata_url.present? && !valid_url?(idp_metadata_url)
errors.add(:settings, "IdP Metadata URL must be a valid URL")
errors.add(:settings, :metadata_url_invalid)
end
if idp_sso_url.present? && !valid_url?(idp_sso_url)
errors.add(:settings, "IdP SSO URL must be a valid URL")
errors.add(:settings, :sso_url_invalid)
end
end

View File

@@ -107,12 +107,12 @@ class Transfer < ApplicationRecord
private
def transfer_has_different_accounts
return unless inflow_transaction&.entry && outflow_transaction&.entry
errors.add(:base, "Must be from different accounts") if to_account == from_account
errors.add(:base, :different_accounts) if to_account == from_account
end
def transfer_has_same_family
return unless inflow_transaction&.entry && outflow_transaction&.entry
errors.add(:base, "Must be from same family") unless to_account&.family == from_account&.family
errors.add(:base, :same_family) unless to_account&.family == from_account&.family
end
def transfer_has_opposite_amounts
@@ -126,10 +126,10 @@ class Transfer < ApplicationRecord
if inflow_entry.currency == outflow_entry.currency
# For same currency, amounts must be exactly opposite
errors.add(:base, "Must have opposite amounts") if inflow_amount + outflow_amount != 0
errors.add(:base, :opposite_amounts) if inflow_amount + outflow_amount != 0
else
# For different currencies, just check the signs are opposite
errors.add(:base, "Must have opposite amounts") unless inflow_amount.negative? && outflow_amount.positive?
errors.add(:base, :opposite_amounts) unless inflow_amount.negative? && outflow_amount.positive?
end
end
@@ -138,6 +138,6 @@ class Transfer < ApplicationRecord
date_diff = (inflow_transaction.entry.date - outflow_transaction.entry.date).abs
max_days = status == "confirmed" ? 30 : 4
errors.add(:base, "Must be within #{max_days} days") if date_diff > max_days
errors.add(:base, :within_days, count: max_days) if date_diff > max_days
end
end

View File

@@ -54,7 +54,7 @@
<% if account.draft? %>
<%= render DS::Link.new(
text: "Complete setup",
text: t(".complete_setup"),
href: edit_account_path(account, return_to: return_to),
variant: :outline,
frame: :modal

View File

@@ -29,13 +29,13 @@
<div class="border-t border-alpha-black-25 px-4 pt-4 text-secondary text-sm justify-between hidden md:flex">
<div class="flex space-x-5">
<div class="flex items-center space-x-2">
<span>Select</span>
<span><%= t(".select") %></span>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center">
<%= icon("corner-down-left", size: "xs") %>
</kbd>
</div>
<div class="flex items-center space-x-2">
<span>Navigate</span>
<span><%= t(".navigate") %></span>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center">
<%= icon("arrow-up", size: "xs") %>
</kbd>
@@ -45,7 +45,7 @@
</div>
</div>
<div class="flex items-center space-x-2">
<button data-action="DS--dialog#close">Close</button>
<button data-action="DS--dialog#close"><%= t(".close") %></button>
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-8 h-5 shrink-0 grow-0 items-center justify-center text-xs">ESC</kbd>
</div>
</div>

View File

@@ -7,11 +7,11 @@
<% unless @account.linked? %>
<% if @account.permission_for(Current.user).in?([ :owner, :full_control ]) %>
<%= render DS::Menu.new(variant: "button") do |menu| %>
<% menu.with_button(text: "New", variant: "secondary", icon: "plus") %>
<% menu.with_button(text: t(".new"), variant: "secondary", icon: "plus") %>
<% menu.with_item(
variant: "link",
text: "New balance",
text: t(".new_balance"),
icon: "circle-dollar-sign",
href: new_valuation_path(account_id: @account.id),
data: { turbo_frame: :modal }) %>
@@ -48,7 +48,7 @@
<%= icon("search") %>
<%= hidden_field_tag :account_id, @account.id %>
<%= form.search_field :search,
placeholder: "Search entries by name",
placeholder: t(".search_placeholder"),
value: @q[:search],
class: "form-field__input placeholder:text-sm placeholder:text-secondary",
"data-auto-submit-form-target": "auto" %>

View File

@@ -17,7 +17,7 @@
<% end %>
<% if account.draft? %>
<%= render DS::Link.new(
text: "Complete setup",
text: t(".complete_setup"),
href: edit_account_path(account),
variant: :outline,
size: :sm,

View File

@@ -3,9 +3,9 @@
<% permission = account.permission_for(Current.user) %>
<%= render DS::Menu.new(testid: "account-menu") do |menu| %>
<% if permission.in?([ :owner, :full_control ]) %>
<% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %>
<% menu.with_item(variant: "link", text: t(".edit"), href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %>
<% end %>
<% menu.with_item(variant: "link", text: "Sharing", href: account_sharing_path(account), icon: "users", data: { turbo_frame: :modal }) %>
<% menu.with_item(variant: "link", text: t(".sharing"), href: account_sharing_path(account), icon: "users", data: { turbo_frame: :modal }) %>
<% menu.with_item(variant: "link", text: t(".statements"), href: account_path(account, tab: "statements"), icon: "archive") %>
<% if permission.in?([ :owner, :full_control ]) %>
@@ -31,7 +31,7 @@
<% if account.owned_by?(Current.user) && !account.linked? %>
<% menu.with_item(
variant: "button",
text: "Delete account",
text: t(".delete_account"),
href: account_path(account),
method: :delete,
icon: "trash-2",

View File

@@ -6,7 +6,7 @@
<%= icon "alert-circle", class: "w-5 h-5 text-destructive mr-2 shrink-0" %>
<div>
<p class="text-sm font-medium text-destructive">
<%= pluralize(sso_provider.errors.count, "error") %> prohibited this provider from being saved:
<%= t("admin.sso_providers.form.errors_title", count: sso_provider.errors.count) %>
</p>
<ul class="mt-2 text-sm text-destructive list-disc list-inside">
<% sso_provider.errors.full_messages.each do |message| %>
@@ -20,38 +20,38 @@
<%= styled_form_with model: [:admin, sso_provider], class: "space-y-6", data: { controller: "admin-sso-form" } do |form| %>
<div class="space-y-4">
<h3 class="font-medium text-primary">Basic Information</h3>
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.basic_information") %></h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<%= form.select :strategy,
options_for_select([
["OpenID Connect", "openid_connect"],
["SAML 2.0", "saml"],
["Google OAuth2", "google_oauth2"],
["GitHub", "github"]
[t("admin.sso_providers.form.strategy_openid_connect"), "openid_connect"],
[t("admin.sso_providers.form.strategy_saml"), "saml"],
[t("admin.sso_providers.form.strategy_google_oauth2"), "google_oauth2"],
[t("admin.sso_providers.form.strategy_github"), "github"]
], sso_provider.strategy),
{ label: "Strategy" },
{ label: t("admin.sso_providers.form.strategy_label") },
{ data: { action: "change->admin-sso-form#toggleFields" } } %>
<%= form.text_field :name,
label: "Name",
placeholder: "e.g., keycloak, authentik",
label: t("admin.sso_providers.form.name_label"),
placeholder: t("admin.sso_providers.form.name_placeholder"),
required: true,
data: { action: "input->admin-sso-form#updateCallbackUrl" } %>
</div>
<p class="text-xs text-secondary -mt-2">Unique identifier (lowercase, numbers, underscores only)</p>
<p class="text-xs text-secondary -mt-2"><%= t("admin.sso_providers.form.name_help") %></p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<%= form.text_field :label,
label: "Button Label",
placeholder: "e.g., Sign in with Keycloak",
label: t("admin.sso_providers.form.label_label"),
placeholder: t("admin.sso_providers.form.label_placeholder"),
required: true %>
<div>
<%= form.text_field :icon,
label: "Icon (optional)",
placeholder: "e.g., key, shield" %>
<p class="text-xs text-secondary mt-1">Lucide icon name for the login button</p>
label: t("admin.sso_providers.form.icon_label"),
placeholder: t("admin.sso_providers.form.icon_placeholder") %>
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.icon_help") %></p>
</div>
</div>
@@ -65,42 +65,42 @@
</div>
<div class="border-t border-primary pt-4 space-y-4">
<h3 class="font-medium text-primary">OAuth/OIDC Configuration</h3>
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.oauth_configuration") %></h3>
<div data-oidc-field class="<%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
<%= form.text_field :issuer,
label: "Issuer URL",
placeholder: "https://your-idp.example.com/realms/your-realm",
label: t("admin.sso_providers.form.issuer_label"),
placeholder: t("admin.sso_providers.form.issuer_placeholder"),
data: { action: "blur->admin-sso-form#validateIssuer" } %>
<p class="text-xs text-secondary mt-1">OIDC issuer URL (validates .well-known/openid-configuration)</p>
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.issuer_help") %></p>
</div>
<%= form.text_field :client_id,
label: "Client ID",
placeholder: "your-client-id",
label: t("admin.sso_providers.form.client_id_label"),
placeholder: t("admin.sso_providers.form.client_id_placeholder"),
required: true %>
<%= form.password_field :client_secret,
label: "Client Secret",
placeholder: sso_provider.persisted? ? "••••••••" : "your-client-secret",
label: t("admin.sso_providers.form.client_secret_label"),
placeholder: sso_provider.persisted? ? t("admin.sso_providers.form.client_secret_placeholder_existing") : t("admin.sso_providers.form.client_secret_placeholder_new"),
required: !sso_provider.persisted? %>
<% if sso_provider.persisted? %>
<p class="text-xs text-secondary -mt-2">Leave blank to keep existing secret</p>
<p class="text-xs text-secondary -mt-2"><%= t("admin.sso_providers.form.client_secret_help_existing") %></p>
<% end %>
<div data-oidc-field class="<%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
<label class="block text-sm font-medium text-primary mb-1">Callback URL</label>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.redirect_uri_label") %></label>
<div class="flex items-center gap-2">
<code class="flex-1 bg-surface px-3 py-2 rounded text-sm text-secondary overflow-x-auto"
data-admin-sso-form-target="callbackUrl"><%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %></code>
<button type="button"
data-action="click->admin-sso-form#copyCallback"
class="p-2 text-secondary hover:text-primary shrink-0"
title="Copy to clipboard">
title="<%= t("admin.sso_providers.form.copy_button") %>">
<%= icon "copy", class: "w-4 h-4" %>
</button>
</div>
<p class="text-xs text-secondary mt-1">Configure this URL in your identity provider</p>
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.redirect_uri_help") %></p>
</div>
</div>
@@ -172,18 +172,18 @@
</details>
<div>
<label class="block text-sm font-medium text-primary mb-1">SP Callback URL (ACS URL)</label>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.saml_sp_callback_url_label") %></label>
<div class="flex items-center gap-2">
<code class="flex-1 bg-surface px-3 py-2 rounded text-sm text-secondary overflow-x-auto"
data-admin-sso-form-target="samlCallbackUrl"><%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %></code>
<button type="button"
data-action="click->admin-sso-form#copySamlCallback"
class="p-2 text-secondary hover:text-primary shrink-0"
title="Copy to clipboard">
title="<%= t("admin.sso_providers.form.copy_button") %>">
<%= icon "copy", class: "w-4 h-4" %>
</button>
</div>
<p class="text-xs text-secondary mt-1">Configure this URL as the Assertion Consumer Service URL in your IdP</p>
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.saml_sp_callback_url_help") %></p>
</div>
</div>
@@ -282,8 +282,8 @@
</div>
<div class="flex gap-3">
<%= link_to "Cancel", admin_sso_providers_path, class: "px-4 py-2 text-sm font-medium text-secondary hover:text-primary" %>
<%= form.submit sso_provider.persisted? ? "Update Provider" : "Create Provider",
<%= link_to t("admin.sso_providers.form.cancel"), admin_sso_providers_path, class: "px-4 py-2 text-sm font-medium text-secondary hover:text-primary" %>
<%= form.submit sso_provider.persisted? ? t("admin.sso_providers.form.update_provider") : t("admin.sso_providers.form.create_provider"),
class: "px-4 py-2 button-bg-primary text-inverse rounded-lg text-sm font-medium hover:button-bg-primary-hover" %>
</div>
</div>

View File

@@ -1,14 +1,14 @@
<%= content_for :page_title, "SSO Providers" %>
<%= content_for :page_title, t(".page_title") %>
<div class="space-y-4">
<p class="text-secondary mb-4">
Manage single sign-on authentication providers for your instance.
<%= t(".description") %>
<% unless FeatureFlags.db_sso_providers? %>
<span class="text-warning">Changes require a server restart to take effect.</span>
<span class="text-warning"><%= t(".restart_required") %></span>
<% end %>
</p>
<%= settings_section title: "Configured Providers" do %>
<%= settings_section title: t(".configured_providers") do %>
<% if @sso_providers.any? %>
<div class="divide-y divide-alpha-black-200 theme-dark:divide-alpha-white-200">
<% @sso_providers.each do |provider| %>
@@ -27,20 +27,20 @@
<div class="flex items-center gap-2">
<% if provider.enabled? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Enabled
<%= t(".enabled") %>
</span>
<% else %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-secondary">
Disabled
<%= t(".disabled") %>
</span>
<% end %>
<%= link_to edit_admin_sso_provider_path(provider), class: "p-1 text-secondary hover:text-primary", title: "Edit" do %>
<%= link_to edit_admin_sso_provider_path(provider), class: "p-1 text-secondary hover:text-primary", title: t(".edit") do %>
<%= icon "pencil", class: "w-4 h-4" %>
<% end %>
<%= button_to toggle_admin_sso_provider_path(provider), method: :patch, class: "p-1 text-secondary hover:text-primary", title: provider.enabled? ? "Disable" : "Enable", form: { data: { turbo_confirm: "Are you sure you want to #{provider.enabled? ? 'disable' : 'enable'} this provider?" } } do %>
<%= button_to toggle_admin_sso_provider_path(provider), method: :patch, class: "p-1 text-secondary hover:text-primary", title: provider.enabled? ? t(".disable") : t(".enable"), form: { data: { turbo_confirm: provider.enabled? ? t("admin.sso_providers.toggle.confirm_disable") : t("admin.sso_providers.toggle.confirm_enable") } } do %>
<%= icon provider.enabled? ? "toggle-right" : "toggle-left", class: "w-4 h-4" %>
<% end %>
<%= button_to admin_sso_provider_path(provider), method: :delete, class: "p-1 text-destructive hover:text-destructive", title: "Delete", form: { data: { turbo_confirm: "Are you sure you want to delete this provider? This action cannot be undone." } } do %>
<%= button_to admin_sso_provider_path(provider), method: :delete, class: "p-1 text-destructive hover:text-destructive", title: t(".delete"), form: { data: { turbo_confirm: t("admin.sso_providers.destroy.confirm") } } do %>
<%= icon "trash-2", class: "w-4 h-4" %>
<% end %>
</div>
@@ -50,14 +50,14 @@
<% else %>
<div class="text-center py-6">
<%= icon "key", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
<p class="text-secondary">No SSO providers configured yet.</p>
<p class="text-secondary"><%= t(".no_providers_message") %></p>
</div>
<% end %>
<div class="pt-4 border-t border-primary">
<%= link_to new_admin_sso_provider_path, class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-secondary" do %>
<%= icon "plus", class: "w-4 h-4" %>
Add Provider
<%= t(".add_provider") %>
<% end %>
</div>
<% end %>
@@ -100,26 +100,25 @@
<% end %>
<% end %>
<%= settings_section title: "Configuration Mode", collapsible: true, open: false do %>
<%= settings_section title: t(".configuration_mode"), collapsible: true, open: false do %>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-primary">Database-backed providers</p>
<p class="text-sm text-secondary">Load providers from database instead of YAML config</p>
<p class="font-medium text-primary"><%= t(".db_backed_providers") %></p>
<p class="text-sm text-secondary"><%= t(".db_backed_providers_description") %></p>
</div>
<% if FeatureFlags.db_sso_providers? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Enabled
<%= t(".enabled") %>
</span>
<% else %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-secondary">
Disabled
<%= t(".disabled") %>
</span>
<% end %>
</div>
<p class="text-sm text-secondary">
Set <code class="bg-surface px-1 py-0.5 rounded text-xs">AUTH_PROVIDERS_SOURCE=db</code> to enable database-backed providers.
This allows changes without server restarts.
<%= t(".db_backed_providers_help_html") %>
</p>
</div>
<% end %>

View File

@@ -9,7 +9,7 @@
<% elsif assistant_message.reasoning? %>
<details class="group mb-1">
<summary class="flex items-center gap-2">
<p class="text-secondary text-sm">Assistant reasoning</p>
<p class="text-secondary text-sm"><%= t(".assistant_reasoning") %></p>
<%= icon("chevron-down", class: "group-open:transform group-open:rotate-180") %>
</summary>

View File

@@ -3,15 +3,15 @@
<details class="my-2 group mb-4">
<summary class="text-secondary text-xs cursor-pointer flex items-center gap-2">
<%= icon("chevron-right", class: "group-open:transform group-open:rotate-90") %>
<p>Tool Calls</p>
<p><%= t(".tool_calls") %></p>
</summary>
<div class="mt-2">
<% message.tool_calls.each do |tool_call| %>
<div class="bg-blue-50 border-blue-200 px-3 py-2 rounded-lg border mb-2">
<p class="text-secondary text-xs">Function:</p>
<p class="text-secondary text-xs"><%= t(".function") %></p>
<p class="text-primary text-sm font-mono"><%= tool_call.function_name %></p>
<p class="text-secondary text-xs mt-2">Arguments:</p>
<p class="text-secondary text-xs mt-2"><%= t(".arguments") %></p>
<pre class="text-primary text-sm font-mono whitespace-pre-wrap"><%= tool_call.function_arguments %></pre>
</div>
<% end %>

View File

@@ -1,6 +1,6 @@
<div id="<%= dom_id(budget, :confirm_button) %>">
<%= render DS::Button.new(
text: "Confirm",
text: t(".confirm"),
variant: "primary",
full_width: true,
href: budget_path(budget),

View File

@@ -1,18 +1,18 @@
<div class="flex justify-center items-center">
<div class="text-center flex flex-col items-center max-w-[500px]">
<h2 class="text-lg text-primary font-medium">Oops!</h2>
<h2 class="text-lg text-primary font-medium"><%= t(".oops") %></h2>
<p class="text-secondary text-sm max-w-sm mx-auto mb-4">
You have not created or assigned any expense categories to your transactions yet.
<%= t(".no_categories_message") %>
</p>
<div class="flex items-center gap-2">
<%= render DS::Button.new(
text: "Use defaults (recommended)",
text: t(".use_defaults"),
href: bootstrap_categories_path,
) %>
<%= render DS::Link.new(
text: "New category",
text: t(".new_category"),
variant: "outline",
icon: "plus",
href: new_category_path,

View File

@@ -8,9 +8,9 @@
<div>
<div class="space-y-6">
<div class="text-center space-y-2">
<h1 class="text-3xl text-primary font-medium">Edit your category budgets</h1>
<h1 class="text-3xl text-primary font-medium"><%= t(".title") %></h1>
<p class="text-secondary text-sm max-w-md mx-auto">
Adjust category budgets to set spending limits. Unallocated funds will be automatically assigned as uncategorized.
<%= t(".description") %>
</p>
</div>

View File

@@ -1,7 +1,7 @@
<%= render DS::Dialog.new(variant: :drawer) do |dialog| %>
<% dialog.with_header do %>
<div>
<p class="text-sm text-secondary">Category</p>
<p class="text-sm text-secondary"><%= t(".category") %></p>
<h3 class="text-2xl font-medium text-primary">
<%= @budget_category.name %>
</h3>
@@ -26,12 +26,12 @@
<% end %>
<% dialog.with_body do %>
<% dialog.with_section(title: "Overview", open: true) do %>
<% dialog.with_section(title: t(".overview"), open: true) do %>
<div class="pb-4">
<dl class="space-y-3 px-3 py-2">
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary">
<%= @budget_category.budget.start_date.strftime("%b %Y") %> spending
<%= t(".spending", date: @budget_category.budget.start_date.strftime("%b %Y")) %>
</dt>
<dd class="text-primary font-medium privacy-sensitive">
<%= format_money @budget_category.actual_spending_money %>
@@ -40,30 +40,30 @@
<% if @budget_category.budget.initialized? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary">Status</dt>
<dt class="text-secondary"><%= t(".status") %></dt>
<% if @budget_category.available_to_spend.negative? %>
<dd class="flex items-center gap-1 text-red-500 font-medium privacy-sensitive">
<%= icon "alert-circle", size: "sm", color: "destructive" %>
<%= format_money @budget_category.available_to_spend_money.abs %>
<span>overspent</span>
<span><%= t(".overspent") %></span>
</dd>
<% elsif @budget_category.available_to_spend.zero? %>
<dd class="flex items-center gap-1 text-orange-500 font-medium privacy-sensitive">
<%= icon "x-circle", size: "sm", color: "warning" %>
<%= format_money @budget_category.available_to_spend_money %>
<span>left</span>
<span><%= t(".left") %></span>
</dd>
<% else %>
<dd class="text-primary flex items-center gap-1 text-green-500 font-medium privacy-sensitive">
<%= icon "check-circle", size: "sm", color: "success" %>
<%= format_money @budget_category.available_to_spend_money %>
<span>left</span>
<span><%= t(".left") %></span>
</dd>
<% end %>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary">Budgeted</dt>
<dt class="text-secondary"><%= t(".budgeted") %></dt>
<dd class="text-primary font-medium privacy-sensitive">
<%= format_money @budget_category.budgeted_spending_money %>
</dd>
@@ -71,14 +71,14 @@
<% end %>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary">Monthly average spending</dt>
<dt class="text-secondary"><%= t(".monthly_average_spending") %></dt>
<dd class="text-primary font-medium privacy-sensitive">
<%= @budget_category.avg_monthly_expense_money.format %>
</dd>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-secondary">Monthly median spending</dt>
<dt class="text-secondary"><%= t(".monthly_median_spending") %></dt>
<dd class="text-primary font-medium privacy-sensitive">
<%= @budget_category.median_monthly_expense_money.format %>
</dd>
@@ -87,7 +87,7 @@
</div>
<% end %>
<% dialog.with_section(title: "Recent Transactions", open: true) do %>
<% dialog.with_section(title: t(".recent_transactions"), open: true) do %>
<div class="space-y-2">
<div class="px-3 py-4 space-y-2">
<% if @recent_transactions.any? %>
@@ -120,7 +120,7 @@
</ul>
<%= render DS::Link.new(
text: "View all category transactions",
text: t(".view_all_transactions"),
variant: "outline",
full_width: true,
href: transactions_path(q: {
@@ -132,7 +132,7 @@
) %>
<% else %>
<p class="text-secondary text-sm mb-4">
No transactions found for this budget period.
<%= t(".no_transactions") %>
</p>
<% end %>
</div>

View File

@@ -2,7 +2,7 @@
<div>
<div class="p-4 border-b border-secondary">
<h3 class="text-sm text-secondary mb-2">Income</h3>
<h3 class="text-sm text-secondary mb-2"><%= t(".income") %></h3>
<span class="inline-block mb-2 text-xl font-medium text-primary privacy-sensitive">
<%= budget.actual_income_money.format %>
@@ -30,7 +30,7 @@
</div>
<div class="p-4">
<h3 class="text-sm text-secondary mb-2">Expenses</h3>
<h3 class="text-sm text-secondary mb-2"><%= t(".expenses") %></h3>
<span class="inline-block mb-2 text-xl font-medium text-primary privacy-sensitive"><%= budget.actual_spending_money.format %></span>

View File

@@ -5,7 +5,7 @@
<div data-donut-chart-target="defaultContent" class="flex flex-col items-center">
<% if budget.initialized? %>
<div class="text-secondary text-sm mb-2">
<span>Spent</span>
<span><%= t(".spent") %></span>
</div>
<div class="mb-2 text-3xl font-medium privacy-sensitive <%= budget.available_to_spend.negative? ? "text-red-500" : "text-primary" %>">
@@ -13,7 +13,7 @@
</div>
<%= render DS::Link.new(
text: "of #{budget.budgeted_spending_money.format}",
text: t(".of_budget", amount: budget.budgeted_spending_money.format),
variant: "secondary",
icon: "pencil",
icon_position: "right",
@@ -26,7 +26,7 @@
</div>
<%= render DS::Link.new(
text: "New budget",
text: t(".new_budget"),
size: "sm",
icon: "plus",
href: edit_budget_path(budget)
@@ -47,7 +47,7 @@
</p>
<%= render DS::Link.new(
text: "of #{bc.budgeted_spending_money.format(precision: 0)}",
text: t(".of_budget", amount: bc.budgeted_spending_money.format(precision: 0)),
variant: "secondary",
icon: "pencil",
icon_position: "right",
@@ -59,7 +59,7 @@
<% end %>
<div id="segment_unused" class="hidden">
<p class="text-sm text-secondary text-center mb-2">Unused</p>
<p class="text-sm text-secondary text-center mb-2"><%= t(".unused") %></p>
<p class="text-3xl font-medium text-primary privacy-sensitive">
<%= format_money(budget.available_to_spend_money) %>

View File

@@ -40,7 +40,7 @@
<div class="ml-auto">
<%= render DS::Link.new(
text: "Today",
text: t(".today"),
variant: "outline",
href: budget_path(Budget.date_to_param(Date.current)),
) %>

View File

@@ -2,7 +2,7 @@
<div>
<div class="p-4 border-b border-secondary">
<h3 class="text-sm text-secondary mb-2">Expected income</h3>
<h3 class="text-sm text-secondary mb-2"><%= t(".expected_income") %></h3>
<span class="inline-block mb-2 text-xl font-medium text-primary privacy-sensitive">
<%= format_money(budget.expected_income_money) %>
@@ -19,12 +19,12 @@
<% end %>
</div>
<div class="flex justify-between text-sm privacy-sensitive">
<p class="text-secondary"><%= format_money(budget.actual_income_money) %> earned</p>
<p class="text-secondary"><%= t(".earned", amount: format_money(budget.actual_income_money)) %></p>
<p class="font-medium">
<% if budget.remaining_expected_income.negative? %>
<span class="text-green-500"><%= format_money(budget.remaining_expected_income_money.abs) %> over</span>
<span class="text-green-500"><%= t(".over", amount: format_money(budget.remaining_expected_income_money.abs)) %></span>
<% else %>
<span class="text-primary"><%= format_money(budget.remaining_expected_income_money) %> left</span>
<span class="text-primary"><%= t(".left", amount: format_money(budget.remaining_expected_income_money)) %></span>
<% end %>
</p>
</div>
@@ -32,7 +32,7 @@
</div>
<div class="p-4">
<h3 class="text-sm text-secondary mb-2">Budgeted</h3>
<h3 class="text-sm text-secondary mb-2"><%= t(".budgeted") %></h3>
<span class="inline-block mb-2 text-xl font-medium text-primary privacy-sensitive">
<%= format_money(budget.budgeted_spending_money) %>
@@ -49,12 +49,12 @@
<% end %>
</div>
<div class="flex justify-between text-sm privacy-sensitive">
<p class="text-secondary"><%= format_money(budget.actual_spending_money) %> spent</p>
<p class="text-secondary"><%= t(".spent", amount: format_money(budget.actual_spending_money)) %></p>
<p class="font-medium">
<% if budget.available_to_spend.negative? %>
<span class="text-destructive"><%= format_money(budget.available_to_spend_money.abs) %> over</span>
<span class="text-destructive"><%= t(".over", amount: format_money(budget.available_to_spend_money.abs)) %></span>
<% else %>
<span class="text-primary"><%= format_money(budget.available_to_spend_money) %> left</span>
<span class="text-primary"><%= t(".left", amount: format_money(budget.available_to_spend_money)) %></span>
<% end %>
</p>
</div>

View File

@@ -2,10 +2,10 @@
<div class="flex flex-col gap-4 items-center justify-center h-full">
<%= icon "alert-triangle", size: "lg", color: "destructive" %>
<p class="text-secondary text-sm text-center">You have over-allocated your budget. Please fix your allocations.</p>
<p class="text-secondary text-sm text-center"><%= t(".over_allocated_message") %></p>
<%= render DS::Link.new(
text: "Fix allocations",
text: t(".fix_allocations"),
variant: "secondary",
size: "sm",
icon: "pencil",

View File

@@ -8,24 +8,24 @@
<div>
<div class="space-y-4">
<div class="text-center space-y-2">
<h1 class="text-3xl text-primary font-medium">Setup your budget</h1>
<h1 class="text-3xl text-primary font-medium"><%= t(".setup_title") %></h1>
<p class="text-secondary text-sm max-w-sm mx-auto">
Enter your monthly earnings and planned spending below to setup your budget.
<%= t(".setup_description") %>
</p>
</div>
<div class="mx-auto max-w-lg">
<%= styled_form_with model: @budget, class: "space-y-3", data: { controller: "budget-form" } do |f| %>
<%= f.money_field :budgeted_spending, label: "Budgeted spending", required: true, disable_currency: true %>
<%= f.money_field :expected_income, label: "Expected income", required: true, disable_currency: true %>
<%= f.money_field :budgeted_spending, label: t(".budgeted_spending"), required: true, disable_currency: true %>
<%= f.money_field :expected_income, label: t(".expected_income"), required: true, disable_currency: true %>
<% if @budget.estimated_income && @budget.estimated_spending %>
<div class="border border-tertiary rounded-lg p-3 flex">
<%= icon "sparkles" %>
<div class="ml-2 space-y-1 text-sm">
<h4 class="text-primary">Autosuggest income & spending budget</h4>
<h4 class="text-primary"><%= t(".autosuggest_title") %></h4>
<p class="text-secondary">
This will be based on transaction history. AI can make mistakes, verify before continuing.
<%= t(".autosuggest_description") %>
</p>
</div>
@@ -40,7 +40,7 @@
</div>
<% end %>
<%= f.submit "Continue" %>
<%= f.submit t(".continue") %>
<% end %>
</div>
</div>

View File

@@ -14,7 +14,7 @@
<div class="fixed right-0 sm:right-auto mx-2 sm:ml-8 sm:mr-0 mt-2 z-50 bg-container p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit" data-color-icon-picker-target="popup">
<div class="flex gap-2 flex-col mb-4" data-color-icon-picker-target="selection" style="<%= "display:none;" if @category.subcategory? %>">
<div data-color-icon-picker-target="pickerSection"></div>
<h4 class="text-secondary text-sm">Color</h4>
<h4 class="text-secondary text-sm"><%= t(".color") %></h4>
<div class="flex flex-wrap md:flex-nowrap gap-2 items-center" data-color-icon-picker-target="colorsSection">
<% Category::COLORS.each do |color| %>
<label class="relative">
@@ -34,14 +34,14 @@
<%= icon "palette", size: "2xl", data: { action: "click->color-icon-picker#toggleSections" } %>
</div>
<div data-color-icon-picker-target="validationMessage" class="hidden self-start flex gap-1 items-center text-xs text-destructive ">
<span>Poor contrast, choose darker color or</span>
<span><%= t(".poor_contrast") %></span>
<button type="button" class="underline cursor-pointer" data-action="color-icon-picker#autoAdjust">auto-adjust.</button>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 justify-center flex-col w-auto md:w-87">
<h4 class="text-secondary text-sm">Icon</h4>
<h4 class="text-secondary text-sm"><%= t(".icon") %></h4>
<div class="flex flex-wrap gap-0.5 max-h-52 overflow-auto">
<% Category.icon_codes.each do |icon| %>
<label class="relative">
@@ -62,9 +62,9 @@
<% end %>
<div class="space-y-2">
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %>
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: t(".name_label"), data: { color_avatar_target: "name" } %>
<% unless category.parent? %>
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" }, disabled: category.parent?, data: { action: "change->color-icon-picker#handleParentChange" } %>
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: t(".unassigned"), label: t(".parent_category_label") }, disabled: category.parent?, data: { action: "change->color-icon-picker#handleParentChange" } %>
<% end %>
</div>
</section>

View File

@@ -3,7 +3,7 @@
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: "Delete all",
text: t(".delete_all"),
href: destroy_all_categories_path,
method: :delete,
icon: "trash-2",

View File

@@ -65,7 +65,7 @@
data: { turbo_frame: "modal" } do %>
<%= icon("refresh-cw") %>
<p>Match transfer/payment</p>
<p><%= t(".match_transfer") %></p>
<% end %>
<% end %>
@@ -93,7 +93,7 @@
<% end %>
</div>
<p>One-time <%= @transaction.entry.amount.negative? ? "income" : "expense" %></p>
<p><%= t(".one_time", type: @transaction.entry.amount.negative? ? t(".income") : t(".expense")) %></p>
<span class="text-orange-500 ml-auto">
<%= icon("asterisk", color: "current") %>

View File

@@ -3,14 +3,13 @@
<%= render "chats/ai_avatar" %>
</div>
<h3 class="text-sm font-medium text-primary mb-1 -mt-2 text-center">Enable AI Chats</h3>
<h3 class="text-sm font-medium text-primary mb-1 -mt-2 text-center"><%= t(".title") %></h3>
<p class="text-secondary mb-4 text-sm text-center">
<% if Current.user.ai_available? %>
AI chat can answer financial questions and provide insights based on your data. To use this feature you'll need to explicitly enable it.
<%= t(".available_description") %>
<% else %>
To use the AI assistant, you need to set the <code class="bg-surface-inset px-1 py-0.5 rounded font-mono text-xs">OPENAI_ACCESS_TOKEN</code>
environment variable or configure it in the Self-Hosting settings of your instance.
<%= t(".unavailable_description_html") %>
<% end %>
</p>
@@ -18,9 +17,9 @@
<%= form_with url: user_path(Current.user), method: :patch, class: "w-full", data: { turbo: false } do |form| %>
<%= form.hidden_field "user[ai_enabled]", value: true %>
<%= form.hidden_field "user[redirect_to]", value: "home" %>
<%= form.submit "Enable AI Chats", class: "cursor-pointer hover:bg-inverse-hover w-full py-2 px-4 bg-inverse text-inverse rounded-lg text-sm font-medium" %>
<%= form.submit t(".enable_button"), class: "cursor-pointer hover:bg-inverse-hover w-full py-2 px-4 bg-inverse text-inverse rounded-lg text-sm font-medium" %>
<% end %>
<% end %>
<p class="text-xs text-secondary text-center mt-2">Disable anytime. All data sent to our LLM providers is anonymized.</p>
<p class="text-xs text-secondary text-center mt-2"><%= t(".disable_note") %></p>
</div>

View File

@@ -2,27 +2,27 @@
<%= render "chats/ai_avatar" %>
<div class="max-w-[85%] text-sm space-y-4 text-primary">
<p>Hey <%= Current.user&.first_name || "there" %>! I'm an AI/large-language-model that can help with your finances. I have access to the web and your account data.</p>
<p><%= t(".greeting", name: Current.user&.first_name || t(".there")) %></p>
<p>
You can use <span class="bg-container border border-secondary px-1.5 py-0.5 rounded font-mono text-xs">/</span> to access commands
<%= t(".commands_hint_html") %>
</p>
<div class="space-y-3">
<p>Here's a few questions you can ask:</p>
<p><%= t(".questions_intro") %></p>
<% questions = [
{
icon: "chart-area",
text: "Evaluate investment portfolio"
text: t(".evaluate_portfolio")
},
{
icon: "wallet-minimal",
text: "Show spending insights"
text: t(".spending_insights")
},
{
icon: "alert-triangle",
text: "Find unusual patterns"
text: t(".unusual_patterns")
}
] %>

View File

@@ -14,14 +14,14 @@
<%= render DS::Menu.new(icon_vertical: true) do |menu| %>
<% menu.with_item(
variant: "link",
text: "Edit chat title",
text: t(".edit_chat_title"),
href: edit_chat_path(chat, ctx: "list"),
icon: "pencil",
frame: dom_id(chat, "title")) %>
<% menu.with_item(
variant: "button",
text: "Delete chat",
text: t(".delete_chat"),
href: chat_path(chat),
icon: "trash-2",
method: :delete,

View File

@@ -10,7 +10,7 @@
icon: "menu",
href: path,
frame: chat_frame,
text: "All chats"
text: t(".all_chats")
) %>
<div class="grow">
@@ -19,19 +19,19 @@
</div>
<%= render DS::Menu.new(icon_vertical: true) do |menu| %>
<% menu.with_item(variant: "link", text: "Start new chat", href: new_chat_path, icon: "plus") %>
<% menu.with_item(variant: "link", text: t(".start_new_chat"), href: new_chat_path, icon: "plus") %>
<% unless chat.new_record? %>
<% menu.with_item(
variant: "link",
text: "Edit chat title",
text: t(".edit_chat_title"),
href: edit_chat_path(chat, ctx: "chat"),
icon: "pencil",
frame: dom_id(chat, "title")) %>
<% menu.with_item(
variant: "button",
text: "Delete chat",
text: t(".delete_chat"),
href: chat_path(chat),
icon: "trash-2",
method: :delete,

View File

@@ -11,7 +11,7 @@
<p class="text-xs text-red-500"><%= chat.presentable_error_message %></p>
<%= render DS::Button.new(
text: "Retry",
text: t(".retry"),
href: retry_chat_path(chat),
) %>
</div>

View File

@@ -10,14 +10,14 @@
<% if @chats.any? %>
<div class="grow flex flex-col">
<div class="flex items-center justify-between my-6">
<h1 class="text-xl font-medium">Chats</h1>
<h1 class="text-xl font-medium"><%= t(".chats") %></h1>
<%= render DS::Link.new(
id: "new-chat",
icon: "plus",
variant: "icon",
href: new_chat_path,
frame: chat_frame,
text: "New chat"
text: t(".new_chat")
) %>
</div>
<div class="space-y-2 px-0.5">
@@ -26,7 +26,7 @@
</div>
<% else %>
<div class="grow flex flex-col">
<h1 class="sr-only">Chats</h1>
<h1 class="sr-only"><%= t(".chats") %></h1>
<div class="mt-auto py-8">
<%= render "chats/ai_greeting" %>
</div>

View File

@@ -28,7 +28,7 @@
<div class="flex justify-center py-8">
<%= render DS::Link.new(
text: "Edit account details",
text: t(".edit_account_details"),
variant: "ghost",
href: edit_credit_card_path(account),
frame: :modal

View File

@@ -14,7 +14,7 @@
<div class="text-center">
<%= render DS::Link.new(
text: "Go back",
text: t("doorkeeper.authorizations.error.go_back"),
href: "javascript:history.back()",
variant: :secondary
) %>

View File

@@ -7,11 +7,11 @@
</div>
<div class="bg-surface-inset rounded-lg p-4">
<p class="text-xs text-secondary mb-2">Authorization Code:</p>
<p class="text-xs text-secondary mb-2"><%= t(".authorization_code_label") %></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.
<%= t(".copy_instructions") %>
</p>
</div>

View File

@@ -20,29 +20,29 @@
<div class="flex items-center gap-2">
<%= tag.p enable_banking_item.institution_display_name, class: "font-medium text-primary" %>
<% if enable_banking_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse">Deletion in progress</p>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
</div>
<p class="text-xs text-secondary">Enable Banking</p>
<p class="text-xs text-secondary"><%= t(".provider_name") %></p>
<% if enable_banking_item.syncing? %>
<div class="text-secondary flex items-center gap-1">
<%= icon "loader", size: "sm", class: "animate-spin" %>
<%= tag.span "Syncing..." %>
<%= tag.span t(".syncing") %>
</div>
<% elsif enable_banking_item.requires_update? %>
<div class="text-warning flex items-center gap-1">
<%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span "Reconnect" %>
<%= tag.span t(".reconnect") %>
</div>
<% else %>
<p class="text-secondary">
<% if enable_banking_item.last_synced_at %>
Last synced <%= time_ago_in_words(enable_banking_item.last_synced_at) %> ago
<%= t(".last_synced", time: time_ago_in_words(enable_banking_item.last_synced_at)) %>
<% if enable_banking_item.sync_status_summary %>
· <%= enable_banking_item.sync_status_summary %>
<% end %>
<% else %>
Never synced
<%= t(".never_synced") %>
<% end %>
</p>
<% end %>
@@ -57,7 +57,7 @@
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg text-white bg-warning hover:opacity-90 transition-colors",
data: { turbo: false } do %>
<%= icon "refresh-cw", size: "sm" %>
Update
<%= t(".update") %>
<% end %>
<% elsif Rails.env.development? %>
<%= icon(
@@ -70,7 +70,7 @@
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: "Delete",
text: t(".delete"),
icon: "trash-2",
href: enable_banking_item_path(enable_banking_item),
method: :delete,
@@ -109,10 +109,10 @@
<% if enable_banking_item.unlinked_accounts_count > 0 %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm">Setup needed</p>
<p class="text-secondary text-sm"><%= pluralize(enable_banking_item.unlinked_accounts_count, "account") %> imported from Enable Banking need to be set up</p>
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
<p class="text-secondary text-sm"><%= t(".setup_needed_description", count: enable_banking_item.unlinked_accounts_count) %></p>
<%= render DS::Link.new(
text: "Set up accounts",
text: t(".set_up_accounts"),
icon: "settings",
variant: "primary",
href: setup_accounts_enable_banking_item_path(enable_banking_item),
@@ -121,8 +121,8 @@
</div>
<% elsif enable_banking_item.accounts.empty? && enable_banking_item.enable_banking_accounts.empty? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm">No accounts found</p>
<p class="text-secondary text-sm">No accounts were found from Enable Banking. Try syncing again.</p>
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_found") %></p>
<p class="text-secondary text-sm"><%= t(".no_accounts_found_description") %></p>
</div>
<% end %>
</div>

View File

@@ -17,22 +17,22 @@
<% if item.session_valid? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary"><%= item.aspsp_name || "Connected Bank" %></p>
<p class="text-sm font-medium text-primary"><%= item.aspsp_name || t(".connected_bank") %></p>
<p class="text-xs text-secondary">
Session expires: <%= item.session_expires_at&.strftime("%b %d, %Y") || "Unknown" %>
<%= t(".session_expires") %>: <%= item.session_expires_at&.strftime("%b %d, %Y") || t(".unknown") %>
</p>
</div>
<% elsif item.session_expired? %>
<div class="w-2 h-2 bg-warning rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary"><%= item.aspsp_name || "Connection" %></p>
<p class="text-xs text-destructive">Session expired - re-authorization required</p>
<p class="text-sm font-medium text-primary"><%= item.aspsp_name || t(".connection") %></p>
<p class="text-xs text-destructive"><%= t(".session_expired") %></p>
</div>
<% else %>
<div class="w-2 h-2 bg-secondary rounded-full"></div>
<div>
<p class="text-sm font-medium text-primary">Configured</p>
<p class="text-xs text-secondary">Ready to connect a bank</p>
<p class="text-sm font-medium text-primary"><%= t(".configured") %></p>
<p class="text-xs text-secondary"><%= t(".ready_to_connect") %></p>
</div>
<% end %>
</div>
@@ -43,28 +43,28 @@
method: :post,
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-primary bg-container border border-primary hover:bg-surface-inset transition-colors",
data: { turbo: false } do %>
Sync
<%= t(".sync") %>
<% end %>
<% elsif item.session_expired? %>
<%= button_to reauthorize_enable_banking_item_path(item),
method: :post,
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-white bg-warning hover:opacity-90 transition-colors",
data: { turbo: false } do %>
Reconnect
<%= t(".reconnect") %>
<% end %>
<% else %>
<%= link_to select_bank_enable_banking_item_path(item),
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-inverse button-bg-primary hover:button-bg-primary-hover transition-colors",
data: { turbo_frame: "modal" } do %>
Connect Bank
<%= t(".connect_bank") %>
<% end %>
<% end %>
<%= button_to enable_banking_item_path(item),
method: :delete,
class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/10 transition-colors",
data: { turbo_confirm: "Are you sure you want to remove this connection?" } do %>
Remove
data: { turbo_confirm: t(".remove_confirm") } do %>
<%= t(".remove") %>
<% end %>
</div>
</div>
@@ -78,7 +78,7 @@
class: "inline-flex items-center gap-2 justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover transition-colors",
data: { turbo_frame: "modal" } do %>
<%= icon "plus", size: "sm" %>
Add Connection
<%= t(".add_connection") %>
<% end %>
</div>
<% end %>
@@ -88,18 +88,18 @@
<div class="flex items-start gap-3">
<%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %>
<div class="text-sm text-secondary">
<p class="font-medium text-primary mb-2">Enable Banking connection not configured</p>
<p>Before you can link Enable Banking accounts, you need to configure your Enable Banking connection.</p>
<p class="font-medium text-primary mb-2"><%= t(".not_configured") %></p>
<p><%= t(".not_configured_description") %></p>
</div>
</div>
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
<p class="font-medium text-primary">Setup Steps:</p>
<p class="font-medium text-primary"><%= t(".setup_steps_title") %></p>
<ol class="list-decimal list-inside space-y-1 text-secondary">
<li>Go to <strong>Settings → Providers</strong></li>
<li>Find the <strong>Enable Banking</strong> section</li>
<li>Enter your Enable Banking credentials</li>
<li>Return here to link your accounts</li>
<li><%= t(".setup_step_1_html") %></li>
<li><%= t(".setup_step_2_html") %></li>
<li><%= t(".setup_step_3") %></li>
<li><%= t(".setup_step_4") %></li>
</ol>
</div>
@@ -107,7 +107,7 @@
<%= link_to settings_providers_path,
class: "w-full inline-flex items-center justify-center rounded-lg font-medium whitespace-nowrap rounded-lg hidden md:inline-flex px-3 py-2 text-sm text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
data: { turbo: false } do %>
Go to Provider Settings
<%= t(".go_to_provider_settings") %>
<% end %>
</div>
</div>

View File

@@ -1,15 +1,15 @@
<%# Modal: Link an existing manual account to a Enable Banking account %>
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Link Enable Banking account") %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
<% if @available_enable_banking_accounts.blank? %>
<div class="p-4 text-sm text-secondary">
<p class="mb-2">All Enable Banking accounts appear to be linked already.</p>
<p class="mb-2"><%= t(".all_linked") %></p>
<ul class="list-disc list-inside space-y-1">
<li>If you just connected or synced, try again after the sync completes.</li>
<li>To link a different account, first unlink it from the accounts actions menu.</li>
<li><%= t(".try_after_sync") %></li>
<li><%= t(".unlink_to_move") %></li>
</ul>
</div>
<% else %>
@@ -22,7 +22,7 @@
<div class="flex flex-col">
<span class="text-sm text-primary font-medium"><%= eba.name.presence || eba.account_id %></span>
<span class="text-xs text-secondary">
<%= eba.currency %> • Balance: <%= number_to_currency((eba.current_balance || 0), unit: eba.currency) %>
<%= eba.currency %> • <%= t(".balance") %>: <%= number_to_currency((eba.current_balance || 0), unit: eba.currency) %>
</span>
</div>
</label>
@@ -30,8 +30,8 @@
</div>
<div class="flex items-center justify-end gap-2">
<%= render DS::Button.new(text: "Link", variant: :primary, icon: "link-2", type: :submit) %>
<%= render DS::Link.new(text: "Cancel", variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
<%= render DS::Button.new(text: t(".link"), variant: :primary, icon: "link-2", type: :submit) %>
<%= render DS::Link.new(text: t(".cancel"), variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
</div>
<% end %>
<% end %>

View File

@@ -1,8 +1,8 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Set Up Your Enable Banking Accounts") do %>
<% dialog.with_header(title: t(".title")) do %>
<div class="flex items-center gap-2">
<%= icon "building-2", class: "text-primary" %>
<span class="text-primary">Choose the correct account types for your imported accounts</span>
<span class="text-primary"><%= t(".header_subtitle") %></span>
</div>
<% end %>
@@ -13,7 +13,7 @@
data: {
controller: "loading-button",
action: "submit->loading-button#showLoading",
loading_button_loading_text_value: "Creating Accounts...",
loading_button_loading_text_value: t(".creating_accounts"),
turbo_frame: "_top"
},
class: "space-y-6" do |form| %>
@@ -24,7 +24,7 @@
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<div>
<p class="text-sm text-primary mb-2">
<strong>Choose the correct account type for each Enable Banking account:</strong>
<strong><%= t(".choose_account_type") %></strong>
</p>
<ul class="text-xs text-secondary space-y-1 list-disc list-inside">
<% @account_type_options.reject { |_, type| type == "skip" }.each do |label, _| %>
@@ -53,15 +53,15 @@
<%= icon "calendar", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<div class="flex-1">
<p class="text-sm text-primary mb-3">
<strong>Historical Data Range:</strong>
<strong><%= t(".historical_data_range") %></strong>
</p>
<%= form.date_field :sync_start_date,
label: "Start syncing transactions from:",
label: t(".sync_start_date_label"),
value: @enable_banking_item.sync_start_date || 3.months.ago.to_date,
min: 2.years.ago.to_date,
max: Date.current,
class: "w-full max-w-xs rounded-md border border-primary px-3 py-2 text-sm bg-container-inset text-primary",
help_text: "Select how far back you want to sync transaction history. Maximum 2 years of history available." %>
help_text: t(".sync_start_date_help") %>
</div>
</div>
</div>
@@ -81,7 +81,7 @@
<p><%= enable_banking_account.account_type_display %></p>
<% end %>
<% if enable_banking_account.current_balance.present? %>
<p>Balance: <%= number_to_currency(enable_banking_account.current_balance, unit: enable_banking_account.currency) %></p>
<p><%= t(".balance") %>: <%= number_to_currency(enable_banking_account.current_balance, unit: enable_banking_account.currency) %></p>
<% end %>
</div>
</div>
@@ -92,7 +92,7 @@
data-account-type-selector-account-id-value="<%= enable_banking_account.id %>"
data-account-type-selector-suggested-subtype-value="<%= enable_banking_account.suggested_subtype %>">
<div>
<%= label_tag "account_types[#{enable_banking_account.id}]", "Account Type:",
<%= label_tag "account_types[#{enable_banking_account.id}]", t(".account_type_label"),
class: "block text-sm font-medium text-primary mb-2" %>
<%= select_tag "account_types[#{enable_banking_account.id}]",
options_for_select(@account_type_options, enable_banking_account.suggested_account_type || "skip"),
@@ -115,7 +115,7 @@
<div class="flex gap-3">
<%= render DS::Button.new(
text: "Create Accounts",
text: t(".create_accounts"),
variant: "primary",
icon: "plus",
type: "submit",
@@ -123,7 +123,7 @@
data: { loading_button_target: "button" }
) %>
<%= render DS::Link.new(
text: "Cancel",
text: t(".cancel"),
variant: "secondary",
href: accounts_path
) %>

View File

@@ -1,40 +1,40 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Export your data", subtitle: "Download all your financial data") %>
<% dialog.with_header(title: t(".dialog_title"), subtitle: t(".dialog_subtitle")) %>
<% dialog.with_body do %>
<div class="space-y-4">
<div class="bg-container-inset rounded-lg p-4 space-y-3">
<h3 class="font-medium text-primary">What's included:</h3>
<h3 class="font-medium text-primary"><%= t(".whats_included") %></h3>
<ul class="space-y-2 text-sm text-secondary">
<li class="flex items-start gap-2">
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
<span>All accounts and balances</span>
<span><%= t(".accounts_and_balances") %></span>
</li>
<li class="flex items-start gap-2">
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
<span>Transaction history</span>
<span><%= t(".transaction_history") %></span>
</li>
<li class="flex items-start gap-2">
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
<span>Investment trades</span>
<span><%= t(".investment_trades") %></span>
</li>
<li class="flex items-start gap-2">
<%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
<span>Categories, tags and rules</span>
<span><%= t(".categories_tags_rules") %></span>
</li>
</ul>
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p class="text-sm text-amber-800">
<strong>Note:</strong> This export includes all of your data, but only some of the data can be imported back via the CSV import feature. We support account, transaction (with category and tags), and trade imports. Other account data cannot be imported and is for your records only.
<strong><%= t(".note_label") %>:</strong> <%= t(".note_description") %>
</p>
</div>
<%= form_with url: family_exports_path, method: :post, class: "space-y-4" do |form| %>
<div class="flex gap-3">
<%= link_to "Cancel", "#", class: "flex-1 text-center px-4 py-2 text-primary bg-surface border border-primary rounded-lg hover:bg-surface-hover", data: { action: "click->modal#close" } %>
<%= form.submit "Export data", class: "flex-1 bg-inverse text-inverse rounded-lg px-4 py-2 cursor-pointer hover:bg-inverse-hover" %>
<%= link_to t(".cancel"), "#", class: "flex-1 text-center px-4 py-2 text-primary bg-surface border border-primary rounded-lg hover:bg-surface-hover", data: { action: "click->modal#close" } %>
<%= form.submit t(".export_data"), class: "flex-1 bg-inverse text-inverse rounded-lg px-4 py-2 cursor-pointer hover:bg-inverse-hover" %>
</div>
<% end %>
</div>

View File

@@ -18,10 +18,10 @@
</td>
<td class="py-3 px-4 text-sm text-right align-middle">
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(variant: "link", text: "Edit", href: edit_family_merchant_path(family_merchant), icon: "pencil", data: { turbo_frame: "modal" }) %>
<% menu.with_item(variant: "link", text: t(".edit"), href: edit_family_merchant_path(family_merchant), icon: "pencil", data: { turbo_frame: "modal" }) %>
<% menu.with_item(
variant: "button",
text: "Delete",
text: t(".delete"),
href: family_merchant_path(family_merchant),
icon: "trash-2",
method: :delete,

View File

@@ -30,7 +30,7 @@
<% else %>
<div class="flex items-center gap-1 px-2 py-0.5 rounded text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors">
<%= icon "pencil", size: "xs" %>
<span class="text-xs">Set</span>
<span class="text-xs"><%= t(".set") %></span>
</div>
<% end %>
<% end %>

View File

@@ -1,14 +1,14 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: "Link Interactive Brokers account") %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
<% if @available_ibkr_accounts.blank? %>
<div class="p-4 text-sm text-secondary">
<p class="mb-2">No unlinked Interactive Brokers accounts are available yet.</p>
<p class="mb-2"><%= t(".no_accounts_available") %></p>
<ul class="list-disc list-inside space-y-1">
<li>Run a sync from Settings &gt; Providers after updating your Flex query.</li>
<li>Wait for the account discovery sync to finish.</li>
<li><%= t(".run_sync_hint") %></li>
<li><%= t(".wait_for_sync") %></li>
</ul>
</div>
<% else %>
@@ -21,7 +21,7 @@
<div class="flex flex-col">
<span class="text-sm text-primary font-medium"><%= ibkr_account.name.presence || ibkr_account.ibkr_account_id %></span>
<span class="text-xs text-secondary">
<%= ibkr_account.currency %> • Balance: <%= number_to_currency((ibkr_account.current_balance || 0), unit: Money::Currency.new(ibkr_account.currency || "USD").symbol) %>
<%= ibkr_account.currency %> • <%= t(".balance") %>: <%= number_to_currency((ibkr_account.current_balance || 0), unit: Money::Currency.new(ibkr_account.currency || "USD").symbol) %>
</span>
</div>
</label>
@@ -29,8 +29,8 @@
</div>
<div class="flex items-center justify-end gap-2">
<%= render DS::Button.new(text: "Link", variant: :primary, icon: "link-2", type: :submit) %>
<%= render DS::Link.new(text: "Cancel", variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
<%= render DS::Button.new(text: t(".link"), variant: :primary, icon: "link-2", type: :submit) %>
<%= render DS::Link.new(text: t(".cancel"), variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
</div>
<% end %>
<% end %>

View File

@@ -1,20 +1,20 @@
<div class="sticky top-0 left-0 w-full bg-black flex items-center justify-between font-mono">
<div class="flex items-center bg-red-600 px-6 py-4">
<%= icon "alert-triangle", size: "lg", color: "current", class: "mr-2" %>
<span class="text-inverse font-semibold uppercase">Super Admin</span>
<span class="text-inverse font-semibold uppercase"><%= t(".super_admin") %></span>
</div>
<div>
<%= link_to "Jobs", sidekiq_web_url, class: "text-white underline hover:text-gray-100" %>
<%= link_to t(".jobs"), sidekiq_web_url, class: "text-white underline hover:text-gray-100" %>
</div>
<div class="flex items-center space-x-2 px-2 py-2 text-white">
<% if Current.session.active_impersonator_session.present? %>
<div class="flex items-center space-x-3 bg-gray-800 border border-gray-700 rounded-md pl-3">
<div class="text-sm">
Impersonating: <span class="font-semibold text-red-400"><%= Current.impersonated_user.email %></span>
<%= t(".impersonating") %>: <span class="font-semibold text-red-400"><%= Current.impersonated_user.email %></span>
</div>
<%= button_to "Leave", leave_impersonation_sessions_path, method: :delete, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<%= button_to "Terminate", complete_impersonation_session_path(Current.session.active_impersonator_session), method: :put, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<%= button_to t(".leave"), leave_impersonation_sessions_path, method: :delete, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<%= button_to t(".terminate"), complete_impersonation_session_path(Current.session.active_impersonator_session), method: :put, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
</div>
<% else %>
<% if Current.true_user.impersonator_support_sessions.in_progress.any? %>
@@ -23,16 +23,16 @@
Current.true_user.impersonator_support_sessions.in_progress.map { |session|
["#{session.impersonated.email} (#{session.status})", session.id]
},
{ prompt: "Join a session" },
{ prompt: t(".join_a_session") },
{ class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono" } %>
<%= f.submit "Join",
<%= f.submit t(".join"),
class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<% end %>
<% end %>
<%= form_with model: ImpersonationSession.new, class: "flex items-center space-x-2" do |f| %>
<%= f.text_field :impersonated_id, class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono w-96", placeholder: "UUID", autocomplete: "off" %>
<%= f.submit "Request Impersonation", class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<%= f.text_field :impersonated_id, class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono w-96", placeholder: t(".uuid_placeholder"), autocomplete: "off" %>
<%= f.submit t(".request_impersonation"), class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<% end %>
<% end %>
</div>

View File

@@ -14,11 +14,11 @@
<div class="bg-container border border-tertiary rounded-lg p-3 flex flex-col md:flex-row items-start md:items-center justify-between gap-2 md:gap-0">
<div class="flex items-center gap-2">
<%= icon "check-circle", size: "sm", color: "success" %>
<p class="text-success text-sm">Your data has been cleaned</p>
<p class="text-success text-sm"><%= t(".data_cleaned") %></p>
</div>
<%= render DS::Link.new(
text: "Next step",
text: t(".next_step"),
variant: "primary",
href: @import.is_a?(PdfImport) ? import_path(@import) : import_confirm_path(@import),
frame: :_top,
@@ -35,8 +35,8 @@
<div class="flex justify-center w-full md:w-auto">
<div class="bg-surface-inset rounded-lg inline-flex p-1 space-x-2 text-sm text-primary font-medium w-full md:w-auto">
<%= link_to "All rows", import_clean_path(@import, per_page: params[:per_page], view: "all"), class: "p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] != 'errors' ? 'bg-container' : ''}" %>
<%= link_to "Error rows", import_clean_path(@import, per_page: params[:per_page], view: "errors"), class: "p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] == 'errors' ? 'bg-container' : ''}" %>
<%= link_to t(".all_rows"), import_clean_path(@import, per_page: params[:per_page], view: "all"), class: "p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] != 'errors' ? 'bg-container' : ''}" %>
<%= link_to t(".error_rows"), import_clean_path(@import, per_page: params[:per_page], view: "errors"), class: "p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] == 'errors' ? 'bg-container' : ''}" %>
</div>
</div>
</div>

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