mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
Merge remote-tracking branch 'origin/main' into feat/goals-v2-architecture
# Conflicts: # app/views/categories/_form.html.erb
This commit is contained in:
6
.github/workflows/chart-ci.yml
vendored
6
.github/workflows/chart-ci.yml
vendored
@@ -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: |
|
||||
|
||||
6
.github/workflows/chart-release.yml
vendored
6
.github/workflows/chart-release.yml
vendored
@@ -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 }}
|
||||
|
||||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/flutter-build.yml
vendored
12
.github/workflows/flutter-build.yml
vendored
@@ -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: |
|
||||
|
||||
2
.github/workflows/google-play-upload.yml
vendored
2
.github/workflows/google-play-upload.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/helm-publish.yml
vendored
8
.github/workflows/helm-publish.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/ios-testflight.yml
vendored
4
.github/workflows/ios-testflight.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/llm-evals.yml
vendored
8
.github/workflows/llm-evals.yml
vendored
@@ -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-*
|
||||
|
||||
8
.github/workflows/mobile-build.yml
vendored
8
.github/workflows/mobile-build.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/mobile-release.yml
vendored
8
.github/workflows/mobile-release.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/pipelock.yml
vendored
3
.github/workflows/pipelock.yml
vendored
@@ -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
216
.github/workflows/preview-cleanup.yml
vendored
Normal 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
189
.github/workflows/preview-deploy.yml
vendored
Normal 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
|
||||
31
.github/workflows/publish.yml
vendored
31
.github/workflows/publish.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.7.1-alpha.7
|
||||
0.7.1-alpha.8
|
||||
|
||||
247
Dockerfile.preview
Normal file
247
Dockerfile.preview
Normal 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"]
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) %>
|
||||
|
||||
@@ -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)),
|
||||
) %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
] %>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
) %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 account’s 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 %>
|
||||
|
||||
@@ -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
|
||||
) %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 > 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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user