feat(integration-requests): community board to request & vote bank integrations (#550)
## What
A community board where users **propose** a bank/service (name + link)
and **vote** on the integrations they want most, so we can prioritise by
real demand.
## How it works
- **Global board**: a shared list sorted by votes desc. `has_voted` and
the monthly quota are per user.
- **Limit**: 3 combined actions (proposal + vote) per user per calendar
month. Creating a proposal **auto-votes** it → costs 2 actions.
- **Moderation**: proposals start as `pending` (visible only to their
author, with a "Pending review" badge); `approved` ones are visible to
everyone. You can't vote on what you can't see (404). The `php artisan
integration-requests:review` command approves/rejects pending ones.
- **Access**:
- Bottom drawer (Vaul, ~95vh) that opens the same board.
- Entry points: the **connect-bank** dialog (bank-selector step), the
**create-account** dialog, and a global **"Request integration"** item
in the user menu (above Support) → available on any screen.
- The `/integration-requests` URL renders the **dashboard** with the
drawer opened on top (behind auth + verified + onboarded + subscribed).
- **Seed**: a migration that inserts Bitunix, XTB, Kraken, Degiro and
Interactive Brokers as `approved` and self-voted by the earliest-created
user (idempotent, no-op when no users exist).
## Endpoints
- `GET /integration-requests` → dashboard + drawer.
- `GET /integration-requests/data` → board state as JSON (feeds the
drawer).
- `POST /integration-requests` → create a proposal (+ auto-vote).
- `POST /integration-requests/{id}/vote` → toggle vote.
## Tests
- 15 Feature tests (access, status visibility, monthly limit, auto-vote,
vote toggle, review command) plus the user-menu tests. All green;
`pint`/`eslint`/`prettier`/`tsc`/PHPStan clean.
## Notes / follow-ups
- No Pennant flag (ships to everyone). No dedup of duplicate proposals
(moderation covers it).
- Seeding 5 integrations to one user uses up that user's monthly quota
(only affects that account via the UI).
This commit is contained in:
parent
9a20335c6a
commit
5e8f227fbd
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\IntegrationRequestStatus;
|
||||
use App\Models\IntegrationRequest;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReviewIntegrationRequestsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'integration-requests:review';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Review pending integration requests and approve or reject them';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$pending = IntegrationRequest::query()
|
||||
->where('status', IntegrationRequestStatus::Pending)
|
||||
->withCount('votes')
|
||||
->with('user:id,email')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
if ($pending->isEmpty()) {
|
||||
$this->info('No pending integration requests.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Name', 'URL', 'Submitted by', 'Votes', 'Created'],
|
||||
$pending->map(fn (IntegrationRequest $request): array => [
|
||||
$request->name,
|
||||
$request->url,
|
||||
$request->user->email,
|
||||
$request->votes_count,
|
||||
$request->created_at?->format('Y-m-d') ?? '—',
|
||||
])->all(),
|
||||
);
|
||||
|
||||
$approved = 0;
|
||||
$rejected = 0;
|
||||
|
||||
foreach ($pending as $request) {
|
||||
$decision = $this->choice(
|
||||
"Review \"{$request->name}\" ({$request->url})",
|
||||
['approve', 'reject', 'skip'],
|
||||
'skip',
|
||||
);
|
||||
|
||||
if ($decision === 'approve') {
|
||||
$request->update(['status' => IntegrationRequestStatus::Approved]);
|
||||
$approved++;
|
||||
} elseif ($decision === 'reject') {
|
||||
$request->update(['status' => IntegrationRequestStatus::Rejected]);
|
||||
$rejected++;
|
||||
}
|
||||
}
|
||||
|
||||
$skipped = $pending->count() - $approved - $rejected;
|
||||
$this->info("Done. Approved: {$approved}, rejected: {$rejected}, skipped: {$skipped}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum IntegrationRequestStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Approved = 'approved';
|
||||
case Rejected = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'Pending',
|
||||
self::Approved => 'Approved',
|
||||
self::Rejected => 'Rejected',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\IntegrationRequestStatus;
|
||||
use App\Http\Requests\StoreIntegrationRequestRequest;
|
||||
use App\Models\IntegrationRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Inertia\Response;
|
||||
|
||||
class IntegrationRequestController extends Controller
|
||||
{
|
||||
private const MONTHLY_ACTION_LIMIT = 3;
|
||||
|
||||
public function index(Request $request, DashboardController $dashboard): Response
|
||||
{
|
||||
// Render the dashboard with the integration-requests drawer opened on top of it.
|
||||
return $dashboard($request)->with('openIntegrationRequests', true);
|
||||
}
|
||||
|
||||
public function data(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return response()->json([
|
||||
'requests' => $this->list($user),
|
||||
'actionsRemaining' => $this->actionsRemaining($user),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreIntegrationRequestRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Creating a request also auto-votes it for the author, so it costs two actions.
|
||||
if ($this->actionsRemaining($user) < 2) {
|
||||
return $this->limitReachedResponse();
|
||||
}
|
||||
|
||||
$integrationRequest = $user->integrationRequests()->create($request->only(['name', 'url']));
|
||||
$integrationRequest->votes()->create(['user_id' => $user->id]);
|
||||
|
||||
return $this->payload($user, 201);
|
||||
}
|
||||
|
||||
public function vote(Request $request, IntegrationRequest $integrationRequest): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($integrationRequest->status !== IntegrationRequestStatus::Approved
|
||||
&& $integrationRequest->user_id !== $user->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$vote = $integrationRequest->votes()->where('user_id', $user->id)->first();
|
||||
|
||||
if ($vote !== null) {
|
||||
$vote->delete();
|
||||
|
||||
return $this->payload($user);
|
||||
}
|
||||
|
||||
if ($this->actionsRemaining($user) <= 0) {
|
||||
return $this->limitReachedResponse();
|
||||
}
|
||||
|
||||
$integrationRequest->votes()->create(['user_id' => $user->id]);
|
||||
|
||||
return $this->payload($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* The board state shared by the page, the drawer and every mutation.
|
||||
*
|
||||
* @return Collection<int, IntegrationRequest>
|
||||
*/
|
||||
private function list(User $user): Collection
|
||||
{
|
||||
return IntegrationRequest::query()
|
||||
->where(function ($query) use ($user) {
|
||||
$query->where('status', IntegrationRequestStatus::Approved)
|
||||
->orWhere(function ($inner) use ($user) {
|
||||
$inner->where('status', IntegrationRequestStatus::Pending)
|
||||
->where('user_id', $user->id);
|
||||
});
|
||||
})
|
||||
->withCount('votes')
|
||||
->withExists(['votes as has_voted' => fn ($query) => $query->where('user_id', $user->id)])
|
||||
->orderByDesc('votes_count')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function actionsRemaining(User $user): int
|
||||
{
|
||||
$start = now()->startOfMonth();
|
||||
|
||||
$used = $user->integrationRequests()->where('created_at', '>=', $start)->count()
|
||||
+ $user->integrationRequestVotes()->where('created_at', '>=', $start)->count();
|
||||
|
||||
return max(0, self::MONTHLY_ACTION_LIMIT - $used);
|
||||
}
|
||||
|
||||
private function payload(User $user, int $status = 200): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'requests' => $this->list($user),
|
||||
'actionsRemaining' => $this->actionsRemaining($user),
|
||||
], $status);
|
||||
}
|
||||
|
||||
private function limitReachedResponse(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => __('You have reached your monthly limit of :count integration actions. Try again next month.', ['count' => self::MONTHLY_ACTION_LIMIT]),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreIntegrationRequestRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'url' => ['required', 'url', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\IntegrationRequestStatus;
|
||||
use Database\Factories\IntegrationRequestFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property string $url
|
||||
* @property IntegrationRequestStatus $status
|
||||
* @property string $user_id
|
||||
*/
|
||||
class IntegrationRequest extends Model
|
||||
{
|
||||
/** @use HasFactory<IntegrationRequestFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'url',
|
||||
'status',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected $hidden = [
|
||||
'user_id',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => IntegrationRequestStatus::class,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<IntegrationRequestVote, $this> */
|
||||
public function votes(): HasMany
|
||||
{
|
||||
return $this->hasMany(IntegrationRequestVote::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\IntegrationRequestVoteFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $integration_request_id
|
||||
* @property string $user_id
|
||||
*/
|
||||
class IntegrationRequestVote extends Model
|
||||
{
|
||||
/** @use HasFactory<IntegrationRequestVoteFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'integration_request_id',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
/** @return BelongsTo<IntegrationRequest, $this> */
|
||||
public function integrationRequest(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(IntegrationRequest::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -148,6 +148,18 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
|
|||
return $this->hasMany(SuggestionRun::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<IntegrationRequest, $this> */
|
||||
public function integrationRequests(): HasMany
|
||||
{
|
||||
return $this->hasMany(IntegrationRequest::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<IntegrationRequestVote, $this> */
|
||||
public function integrationRequestVotes(): HasMany
|
||||
{
|
||||
return $this->hasMany(IntegrationRequestVote::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user has an active, current-version AI consent.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\IntegrationRequestStatus;
|
||||
use App\Models\IntegrationRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<IntegrationRequest>
|
||||
*/
|
||||
class IntegrationRequestFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->company(),
|
||||
'url' => fake()->url(),
|
||||
'status' => IntegrationRequestStatus::Pending,
|
||||
'user_id' => User::factory(),
|
||||
];
|
||||
}
|
||||
|
||||
public function approved(): static
|
||||
{
|
||||
return $this->state(['status' => IntegrationRequestStatus::Approved]);
|
||||
}
|
||||
|
||||
public function rejected(): static
|
||||
{
|
||||
return $this->state(['status' => IntegrationRequestStatus::Rejected]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\IntegrationRequest;
|
||||
use App\Models\IntegrationRequestVote;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<IntegrationRequestVote>
|
||||
*/
|
||||
class IntegrationRequestVoteFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'integration_request_id' => IntegrationRequest::factory(),
|
||||
'user_id' => User::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('integration_requests', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('url', 2048);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('integration_requests');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('integration_request_votes', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('integration_request_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['integration_request_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('integration_request_votes');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('integration_requests', function (Blueprint $table) {
|
||||
$table->string('status')->default('pending')->index()->after('url');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('integration_requests', function (Blueprint $table) {
|
||||
$table->dropColumn('status');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\IntegrationRequestStatus;
|
||||
use App\Models\IntegrationRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* @var list<array{name: string, url: string}>
|
||||
*/
|
||||
private array $integrations = [
|
||||
['name' => 'Bitunix', 'url' => 'https://www.bitunix.com/es-es'],
|
||||
['name' => 'XTB', 'url' => 'https://www.xtb.com/int'],
|
||||
['name' => 'Kraken', 'url' => 'https://www.kraken.com/'],
|
||||
['name' => 'Degiro', 'url' => 'https://www.degiro.es/'],
|
||||
['name' => 'Interactive Brokers', 'url' => 'https://www.interactivebrokers.com/'],
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$user = User::query()->oldest('created_at')->first();
|
||||
|
||||
if ($user === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->integrations as $integration) {
|
||||
$request = IntegrationRequest::query()->firstOrCreate(
|
||||
['name' => $integration['name'], 'user_id' => $user->id],
|
||||
['url' => $integration['url'], 'status' => IntegrationRequestStatus::Approved],
|
||||
);
|
||||
|
||||
$request->votes()->firstOrCreate(['user_id' => $user->id]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$user = User::query()->oldest('created_at')->first();
|
||||
|
||||
if ($user === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
IntegrationRequest::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('name', array_column($this->integrations, 'name'))
|
||||
->delete();
|
||||
}
|
||||
};
|
||||
12
lang/es.json
12
lang/es.json
|
|
@ -1,4 +1,16 @@
|
|||
{
|
||||
"Link": "Enlace",
|
||||
"Submit": "Enviar",
|
||||
"Pending review": "Pendiente de revisión",
|
||||
"Something went wrong.": "Algo salió mal.",
|
||||
"Integration requests": "Solicitudes de integración",
|
||||
"Request integration": "Solicitar integración",
|
||||
"Can't find your bank? Request or vote for it": "¿No encuentras tu banco? Solicítalo o vótalo",
|
||||
"e.g. Revolut": "p. ej. Revolut",
|
||||
"You have :count actions left this month.": "Te quedan :count acciones este mes.",
|
||||
"No integrations requested yet. Be the first!": "Aún no hay integraciones solicitadas. ¡Sé el primero!",
|
||||
"Request a bank or service and vote for the ones you want us to add next.": "Solicita un banco o servicio y vota los que quieres que añadamos a continuación.",
|
||||
"You have reached your monthly limit of :count integration actions. Try again next month.": "Has alcanzado tu límite mensual de :count acciones de integración. Inténtalo de nuevo el mes que viene.",
|
||||
"Categorized by AI": "Categorizado por IA",
|
||||
"Categorized by AI with :confidence% confident": "Categorizado por IA con un :confidence % de confianza",
|
||||
"Only show AI guesses": "Mostrar solo sugerencias de IA",
|
||||
|
|
|
|||
14
lang/fr.json
14
lang/fr.json
|
|
@ -1,4 +1,16 @@
|
|||
{
|
||||
"Link": "Lien",
|
||||
"Submit": "Envoyer",
|
||||
"Pending review": "En attente de validation",
|
||||
"Something went wrong.": "Une erreur s'est produite.",
|
||||
"Integration requests": "Demandes d'intégration",
|
||||
"Request integration": "Demander une intégration",
|
||||
"Can't find your bank? Request or vote for it": "Vous ne trouvez pas votre banque ? Demandez-la ou votez",
|
||||
"e.g. Revolut": "p. ex. Revolut",
|
||||
"You have :count actions left this month.": "Il vous reste :count actions ce mois-ci.",
|
||||
"No integrations requested yet. Be the first!": "Aucune intégration demandée pour l'instant. Soyez le premier !",
|
||||
"Request a bank or service and vote for the ones you want us to add next.": "Demandez une banque ou un service et votez pour ceux que vous voulez que nous ajoutions ensuite.",
|
||||
"You have reached your monthly limit of :count integration actions. Try again next month.": "Vous avez atteint votre limite mensuelle de :count actions d'intégration. Réessayez le mois prochain.",
|
||||
"Analyze": "Analyser",
|
||||
"Monthly average": "Moyenne mensuelle",
|
||||
"Trend": "S'orienter",
|
||||
|
|
@ -2092,4 +2104,4 @@
|
|||
"All untracked expenses": "Toutes les dépenses non suivies",
|
||||
"Automatically track every expense that no other budget covers. You can only have one.": "Suit automatiquement chaque dépense qu'aucun autre budget ne couvre. Vous ne pouvez en avoir qu'un seul.",
|
||||
"This catch-all budget tracks every expense that no other budget covers.": "Ce budget général suit chaque dépense qu'aucun autre budget ne couvre."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { store } from '@/actions/App/Http/Controllers/Settings/AccountController';
|
||||
import { store as storeBank } from '@/actions/App/Http/Controllers/Settings/BankController';
|
||||
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
|
||||
import { ConnectAccountDialog } from '@/components/open-banking/connect-account-dialog';
|
||||
import { UpgradeConnectionDialog } from '@/components/open-banking/upgrade-connection-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -51,6 +52,7 @@ export function CreateAccountDialog({
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [connectDialogOpen, setConnectDialogOpen] = useState(false);
|
||||
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
||||
const [integrationDrawerOpen, setIntegrationDrawerOpen] = useState(false);
|
||||
const formDataRef = useRef<AccountFormData>({
|
||||
displayName: '',
|
||||
bankId: null,
|
||||
|
|
@ -337,6 +339,11 @@ export function CreateAccountDialog({
|
|||
open={upgradeDialogOpen}
|
||||
onOpenChange={setUpgradeDialogOpen}
|
||||
/>
|
||||
|
||||
<IntegrationRequestsDrawer
|
||||
open={integrationDrawerOpen}
|
||||
onOpenChange={setIntegrationDrawerOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
import {
|
||||
data,
|
||||
store,
|
||||
vote,
|
||||
} from '@/actions/App/Http/Controllers/IntegrationRequestController';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getCsrfToken } from '@/lib/csrf';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { ChevronUp } from 'lucide-react';
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
|
||||
export interface IntegrationRequestItem {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
votes_count: number;
|
||||
has_voted: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface BoardPayload {
|
||||
requests: IntegrationRequestItem[];
|
||||
actionsRemaining: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialRequests?: IntegrationRequestItem[];
|
||||
initialActionsRemaining?: number;
|
||||
}
|
||||
|
||||
async function sendJson(
|
||||
url: string,
|
||||
method: 'GET' | 'POST',
|
||||
body?: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
return fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function IntegrationRequestsBoard({
|
||||
initialRequests,
|
||||
initialActionsRemaining,
|
||||
}: Props) {
|
||||
const [requests, setRequests] = useState<IntegrationRequestItem[] | null>(
|
||||
initialRequests ?? null,
|
||||
);
|
||||
const [actionsRemaining, setActionsRemaining] = useState<number>(
|
||||
initialActionsRemaining ?? 0,
|
||||
);
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialRequests !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const response = await sendJson(data().url, 'GET');
|
||||
if (response.ok) {
|
||||
const payload: BoardPayload = await response.json();
|
||||
setRequests(payload.requests);
|
||||
setActionsRemaining(payload.actionsRemaining);
|
||||
}
|
||||
})();
|
||||
}, [initialRequests]);
|
||||
|
||||
const apply = (payload: BoardPayload) => {
|
||||
setRequests(payload.requests);
|
||||
setActionsRemaining(payload.actionsRemaining);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const response = await sendJson(store().url, 'POST', { name, url });
|
||||
|
||||
if (response.ok) {
|
||||
apply(await response.json());
|
||||
setName('');
|
||||
setUrl('');
|
||||
setShowForm(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
setError(body.message ?? __('Something went wrong.'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVote = async (item: IntegrationRequestItem) => {
|
||||
if (busy || (!item.has_voted && actionsRemaining <= 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const response = await sendJson(vote(item.id).url, 'POST');
|
||||
|
||||
if (response.ok) {
|
||||
apply(await response.json());
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
setError(body.message ?? __('Something went wrong.'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const outOfActions = actionsRemaining <= 0;
|
||||
// A new request also auto-votes it, so it costs two actions.
|
||||
const cannotRequest = actionsRemaining < 2;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('You have :count actions left this month.', {
|
||||
count: actionsRemaining,
|
||||
})}
|
||||
</p>
|
||||
{!showForm && (
|
||||
<Button
|
||||
onClick={() => setShowForm(true)}
|
||||
disabled={cannotRequest}
|
||||
>
|
||||
{__('Request integration')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-3 rounded-lg border p-4"
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="integration-name">
|
||||
{__('Name')}
|
||||
</Label>
|
||||
<Input
|
||||
id="integration-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={__('e.g. Revolut')}
|
||||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="integration-url">
|
||||
{__('Link')}
|
||||
</Label>
|
||||
<Input
|
||||
id="integration-url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://..."
|
||||
maxLength={2048}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={busy || cannotRequest}>
|
||||
{__('Submit')}
|
||||
</Button>
|
||||
</div>
|
||||
<InputError message={error ?? undefined} />
|
||||
</form>
|
||||
)}
|
||||
|
||||
{requests === null ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{__('No integrations requested yet. Be the first!')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{requests.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate font-medium hover:underline"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
{item.status === 'pending' && (
|
||||
<Badge variant="secondary">
|
||||
{__('Pending review')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant={item.has_voted ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={
|
||||
busy || (!item.has_voted && outOfActions)
|
||||
}
|
||||
onClick={() => handleVote(item)}
|
||||
aria-pressed={item.has_voted}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
{item.votes_count}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { IntegrationRequestsBoard } from '@/components/integration-requests/integration-requests-board';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/drawer';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function IntegrationRequestsDrawer({ open, onOpenChange }: Props) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="h-[95vh] data-[vaul-drawer-direction=bottom]:max-h-[95vh]">
|
||||
<div className="mx-auto w-full max-w-2xl overflow-y-auto p-6">
|
||||
<DrawerHeader className="px-0">
|
||||
<DrawerTitle>{__('Integration requests')}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{__(
|
||||
'Request a bank or service and vote for the ones you want us to add next.',
|
||||
)}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<IntegrationRequestsBoard />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
|
||||
import { SupportDialog } from '@/components/support-dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -23,6 +24,8 @@ export function NavUser({ className }: { className?: string }) {
|
|||
const { state } = useSidebar();
|
||||
const isMobile = useIsMobile();
|
||||
const [supportOpen, setSupportOpen] = useState(false);
|
||||
const [integrationRequestsOpen, setIntegrationRequestsOpen] =
|
||||
useState(false);
|
||||
|
||||
return (
|
||||
<SidebarMenu className={className}>
|
||||
|
|
@ -52,6 +55,9 @@ export function NavUser({ className }: { className?: string }) {
|
|||
<UserMenuContent
|
||||
user={auth.user}
|
||||
onOpenSupport={() => setSupportOpen(true)}
|
||||
onOpenIntegrationRequests={() =>
|
||||
setIntegrationRequestsOpen(true)
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -60,6 +66,10 @@ export function NavUser({ className }: { className?: string }) {
|
|||
onOpenChange={setSupportOpen}
|
||||
user={auth.user}
|
||||
/>
|
||||
<IntegrationRequestsDrawer
|
||||
open={integrationRequestsOpen}
|
||||
onOpenChange={setIntegrationRequestsOpen}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { BankLogo } from '@/components/bank-logo';
|
||||
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -95,6 +96,7 @@ export function ConnectAccountDialog({
|
|||
connections = [],
|
||||
}: ConnectAccountDialogProps) {
|
||||
const [step, setStep] = useState<Step>('country');
|
||||
const [integrationDrawerOpen, setIntegrationDrawerOpen] = useState(false);
|
||||
const [country, setCountry] = useState<string>('');
|
||||
const [institutions, setInstitutions] = useState<
|
||||
EnableBankingInstitution[]
|
||||
|
|
@ -311,421 +313,454 @@ export function ConnectAccountDialog({
|
|||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Connect Bank Account')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'country' &&
|
||||
__(
|
||||
'Select the country where your bank is located.',
|
||||
)}
|
||||
{step === 'bank' && __('Select your bank.')}
|
||||
{step === 'confirm' &&
|
||||
isWise &&
|
||||
__(
|
||||
'Enter your Wise Personal API token to connect your account.',
|
||||
)}
|
||||
{step === 'confirm' &&
|
||||
!isIndexaCapital &&
|
||||
!isBinance &&
|
||||
!isBitpanda &&
|
||||
!isCoinbase &&
|
||||
!isWise &&
|
||||
__(
|
||||
'You will be redirected to your bank to authorize access.',
|
||||
)}
|
||||
{step === 'confirm' &&
|
||||
isIndexaCapital &&
|
||||
__(
|
||||
'Enter your API token to connect your Indexa Capital account.',
|
||||
)}
|
||||
{step === 'confirm' &&
|
||||
isBinance &&
|
||||
__(
|
||||
'Enter your API Key and Secret to connect your Binance account.',
|
||||
)}
|
||||
{step === 'confirm' &&
|
||||
isBitpanda &&
|
||||
__(
|
||||
'Enter your API Key to connect your Bitpanda account.',
|
||||
)}
|
||||
{step === 'confirm' &&
|
||||
isCoinbase &&
|
||||
__(
|
||||
'Enter your CDP App Key ID and Secret to connect your Coinbase account.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Connect Bank Account')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'country' &&
|
||||
__(
|
||||
'Select the country where your bank is located.',
|
||||
)}
|
||||
{step === 'bank' && __('Select your bank.')}
|
||||
{step === 'confirm' &&
|
||||
isWise &&
|
||||
__(
|
||||
'Enter your Wise Personal API token to connect your account.',
|
||||
)}
|
||||
{step === 'confirm' &&
|
||||
!isIndexaCapital &&
|
||||
!isBinance &&
|
||||
!isBitpanda &&
|
||||
!isCoinbase &&
|
||||
!isWise &&
|
||||
__(
|
||||
'You will be redirected to your bank to authorize access.',
|
||||
)}
|
||||
{step === 'confirm' &&
|
||||
isIndexaCapital &&
|
||||
__(
|
||||
'Enter your API token to connect your Indexa Capital account.',
|
||||
)}
|
||||
{step === 'confirm' &&
|
||||
isBinance &&
|
||||
__(
|
||||
'Enter your API Key and Secret to connect your Binance account.',
|
||||
)}
|
||||
{step === 'confirm' &&
|
||||
isBitpanda &&
|
||||
__(
|
||||
'Enter your API Key to connect your Bitpanda account.',
|
||||
)}
|
||||
{step === 'confirm' &&
|
||||
isCoinbase &&
|
||||
__(
|
||||
'Enter your CDP App Key ID and Secret to connect your Coinbase account.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{step === 'country' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Country')}</Label>
|
||||
<Select value={country} onValueChange={setCountry}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={__('Select country')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COUNTRIES.map((c) => (
|
||||
<SelectItem key={c.code} value={c.code}>
|
||||
{__(c.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!country || isLoading}
|
||||
onClick={() => fetchInstitutions(country)}
|
||||
>
|
||||
{isLoading ? __('Loading...') : __('Continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'bank' && (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder={__('Search banks...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="max-h-[300px] space-y-1 overflow-y-auto">
|
||||
{filteredInstitutions.map((institution) => (
|
||||
<button
|
||||
key={institution.name}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
|
||||
selectedBank?.name === institution.name
|
||||
? 'bg-accent'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => setSelectedBank(institution)}
|
||||
{step === 'country' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Country')}</Label>
|
||||
<Select
|
||||
value={country}
|
||||
onValueChange={setCountry}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={__('Select country')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COUNTRIES.map((c) => (
|
||||
<SelectItem
|
||||
key={c.code}
|
||||
value={c.code}
|
||||
>
|
||||
{__(c.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!country || isLoading}
|
||||
onClick={() => fetchInstitutions(country)}
|
||||
>
|
||||
{isLoading
|
||||
? __('Loading...')
|
||||
: __('Continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'bank' && (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder={__('Search banks...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="max-h-[300px] space-y-1 overflow-y-auto">
|
||||
{filteredInstitutions.map((institution) => (
|
||||
<button
|
||||
key={institution.name}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
|
||||
selectedBank?.name ===
|
||||
institution.name
|
||||
? 'bg-accent'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
setSelectedBank(institution)
|
||||
}
|
||||
>
|
||||
<BankLogo
|
||||
src={institution.logo}
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
<span>{institution.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredInstitutions.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{__('No banks found.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-b border-dotted text-sm text-primary hover:border-solid"
|
||||
onClick={() => setIntegrationDrawerOpen(true)}
|
||||
>
|
||||
{__(
|
||||
"Can't find your bank? Request or vote for it",
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep('country')}
|
||||
>
|
||||
{__('Back')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedBank}
|
||||
onClick={() => setStep('confirm')}
|
||||
>
|
||||
{__('Continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && selectedBank && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<BankLogo
|
||||
src={institution.logo}
|
||||
className="h-6 w-6"
|
||||
src={selectedBank.logo}
|
||||
className="size-16 p-1"
|
||||
/>
|
||||
<span>{institution.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredInstitutions.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{__('No banks found.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep('country')}
|
||||
>
|
||||
{__('Back')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedBank}
|
||||
onClick={() => setStep('confirm')}
|
||||
>
|
||||
{__('Continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && selectedBank && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<BankLogo
|
||||
src={selectedBank.logo}
|
||||
className="size-16 p-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{selectedBank.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isBitpanda
|
||||
? __(
|
||||
'Connect your Bitpanda account using your API Key.',
|
||||
)
|
||||
: isBinance
|
||||
? __(
|
||||
'Connect your Binance account using your API Key and Secret.',
|
||||
)
|
||||
: isIndexaCapital
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{selectedBank.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isBitpanda
|
||||
? __(
|
||||
'Connect your Indexa Capital account using your API token.',
|
||||
'Connect your Bitpanda account using your API Key.',
|
||||
)
|
||||
: isCoinbase
|
||||
: isBinance
|
||||
? __(
|
||||
'Connect your Coinbase account using a CDP API key.',
|
||||
'Connect your Binance account using your API Key and Secret.',
|
||||
)
|
||||
: isWise
|
||||
: isIndexaCapital
|
||||
? __(
|
||||
'Connect your Wise account using a Personal API token.',
|
||||
'Connect your Indexa Capital account using your API token.',
|
||||
)
|
||||
: __(
|
||||
'You will be redirected to authorize access to your account data.',
|
||||
)}
|
||||
</p>
|
||||
: isCoinbase
|
||||
? __(
|
||||
'Connect your Coinbase account using a CDP API key.',
|
||||
)
|
||||
: isWise
|
||||
? __(
|
||||
'Connect your Wise account using a Personal API token.',
|
||||
)
|
||||
: __(
|
||||
'You will be redirected to authorize access to your account data.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isIndexaCapital && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-token">
|
||||
{__('API Token')}
|
||||
</Label>
|
||||
<Input
|
||||
id="api-token"
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) =>
|
||||
setApiToken(e.target.value)
|
||||
}
|
||||
placeholder={__(
|
||||
'Paste your Indexa Capital API token',
|
||||
)}
|
||||
className="my-2"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can generate your API token from your Indexa Capital dashboard under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://indexacapital.com/es/u/user#settings-apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('Settings > Applications')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBinance && (
|
||||
<div className="space-y-4">
|
||||
{isIndexaCapital && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key">
|
||||
<Label htmlFor="api-token">
|
||||
{__('API Token')}
|
||||
</Label>
|
||||
<Input
|
||||
id="api-token"
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) =>
|
||||
setApiToken(e.target.value)
|
||||
}
|
||||
placeholder={__(
|
||||
'Paste your Indexa Capital API token',
|
||||
)}
|
||||
className="my-2"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can generate your API token from your Indexa Capital dashboard under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://indexacapital.com/es/u/user#settings-apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('Settings > Applications')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBinance && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key">
|
||||
{__('API Key')}
|
||||
</Label>
|
||||
<Input
|
||||
id="api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) =>
|
||||
setApiKey(e.target.value)
|
||||
}
|
||||
className="mt-1"
|
||||
placeholder={__(
|
||||
'Paste your Binance API Key',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-secret">
|
||||
{__('API Secret')}
|
||||
</Label>
|
||||
<Input
|
||||
id="api-secret"
|
||||
type="password"
|
||||
value={apiSecret}
|
||||
onChange={(e) =>
|
||||
setApiSecret(e.target.value)
|
||||
}
|
||||
className="mt-1"
|
||||
placeholder={__(
|
||||
'Paste your Binance API Secret',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can create API keys from your Binance account under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://www.binance.com/es/my/settings/api-management"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Management')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBitpanda && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bitpanda-api-key">
|
||||
{__('API Key')}
|
||||
</Label>
|
||||
<Input
|
||||
id="api-key"
|
||||
id="bitpanda-api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
value={bitpandaApiKey}
|
||||
onChange={(e) =>
|
||||
setApiKey(e.target.value)
|
||||
setBitpandaApiKey(e.target.value)
|
||||
}
|
||||
className="mt-1"
|
||||
placeholder={__(
|
||||
'Paste your Binance API Key',
|
||||
'Paste your Bitpanda API Key',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-secret">
|
||||
{__('API Secret')}
|
||||
</Label>
|
||||
<Input
|
||||
id="api-secret"
|
||||
type="password"
|
||||
value={apiSecret}
|
||||
onChange={(e) =>
|
||||
setApiSecret(e.target.value)
|
||||
}
|
||||
className="mt-1"
|
||||
placeholder={__(
|
||||
'Paste your Binance API Secret',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can create API keys from your Binance account under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://www.binance.com/es/my/settings/api-management"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Management')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBitpanda && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bitpanda-api-key">
|
||||
{__('API Key')}
|
||||
</Label>
|
||||
<Input
|
||||
id="bitpanda-api-key"
|
||||
type="password"
|
||||
value={bitpandaApiKey}
|
||||
onChange={(e) =>
|
||||
setBitpandaApiKey(e.target.value)
|
||||
}
|
||||
className="mt-1"
|
||||
placeholder={__(
|
||||
'Paste your Bitpanda API Key',
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can create API keys from your Bitpanda account under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://web.bitpanda.com/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Key Management')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWise && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wise-api-token">
|
||||
{__('Personal API Token')}
|
||||
</Label>
|
||||
<Input
|
||||
id="wise-api-token"
|
||||
type="password"
|
||||
value={wiseApiToken}
|
||||
onChange={(e) =>
|
||||
setWiseApiToken(e.target.value)
|
||||
}
|
||||
className="mt-1"
|
||||
placeholder={__(
|
||||
'Paste your Wise API token',
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Generate a token in Wise under')}{' '}
|
||||
<a
|
||||
href="https://wise.com/user/account#/developer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'Settings → Developer Tools → API tokens',
|
||||
)}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
'You can create API keys from your Bitpanda account under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://web.bitpanda.com/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Key Management')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCoinbase && (
|
||||
<div className="space-y-4">
|
||||
{isWise && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coinbase-key-name">
|
||||
{__('App Key ID')}
|
||||
<Label htmlFor="wise-api-token">
|
||||
{__('Personal API Token')}
|
||||
</Label>
|
||||
<Input
|
||||
id="coinbase-key-name"
|
||||
type="text"
|
||||
value={coinbaseKeyName}
|
||||
id="wise-api-token"
|
||||
type="password"
|
||||
value={wiseApiToken}
|
||||
onChange={(e) =>
|
||||
setCoinbaseKeyName(e.target.value)
|
||||
setWiseApiToken(e.target.value)
|
||||
}
|
||||
className="mt-1 font-mono text-xs"
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
className="mt-1"
|
||||
placeholder={__(
|
||||
'Paste your Wise API token',
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Generate a token in Wise under')}{' '}
|
||||
<a
|
||||
href="https://wise.com/user/account#/developer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__(
|
||||
'Settings → Developer Tools → API tokens',
|
||||
)}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coinbase-private-key">
|
||||
{__('Secret')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="coinbase-private-key"
|
||||
value={coinbasePrivateKey}
|
||||
onChange={(e) =>
|
||||
setCoinbasePrivateKey(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
rows={6}
|
||||
className="mt-1 font-mono text-xs"
|
||||
placeholder={
|
||||
'Paste your CDP API key secret'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://portal.cdp.coinbase.com/access/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Keys')}
|
||||
</a>
|
||||
. {__('Use a view-only key.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep('bank')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{__('Back')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAuthorize}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
(isIndexaCapital && !apiToken) ||
|
||||
(isBinance && (!apiKey || !apiSecret)) ||
|
||||
(isBitpanda && !bitpandaApiKey) ||
|
||||
(isCoinbase &&
|
||||
(!coinbaseKeyName ||
|
||||
!coinbasePrivateKey)) ||
|
||||
(isWise && !wiseApiToken)
|
||||
}
|
||||
>
|
||||
{isSubmitting
|
||||
? __('Connecting...')
|
||||
: __('Connect')}
|
||||
</Button>
|
||||
{isCoinbase && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coinbase-key-name">
|
||||
{__('App Key ID')}
|
||||
</Label>
|
||||
<Input
|
||||
id="coinbase-key-name"
|
||||
type="text"
|
||||
value={coinbaseKeyName}
|
||||
onChange={(e) =>
|
||||
setCoinbaseKeyName(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="mt-1 font-mono text-xs"
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coinbase-private-key">
|
||||
{__('Secret')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="coinbase-private-key"
|
||||
value={coinbasePrivateKey}
|
||||
onChange={(e) =>
|
||||
setCoinbasePrivateKey(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
rows={6}
|
||||
className="mt-1 font-mono text-xs"
|
||||
placeholder={
|
||||
'Paste your CDP API key secret'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://portal.cdp.coinbase.com/access/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Keys')}
|
||||
</a>
|
||||
. {__('Use a view-only key.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep('bank')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{__('Back')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAuthorize}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
(isIndexaCapital && !apiToken) ||
|
||||
(isBinance &&
|
||||
(!apiKey || !apiSecret)) ||
|
||||
(isBitpanda && !bitpandaApiKey) ||
|
||||
(isCoinbase &&
|
||||
(!coinbaseKeyName ||
|
||||
!coinbasePrivateKey)) ||
|
||||
(isWise && !wiseApiToken)
|
||||
}
|
||||
>
|
||||
{isSubmitting
|
||||
? __('Connecting...')
|
||||
: __('Connect')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<IntegrationRequestsDrawer
|
||||
open={integrationDrawerOpen}
|
||||
onOpenChange={setIntegrationDrawerOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,13 @@ const user: User = {
|
|||
|
||||
describe('UserMenuContent', () => {
|
||||
it('shows community above feedback in the user dropdown', () => {
|
||||
render(<UserMenuContent user={user} onOpenSupport={vi.fn()} />);
|
||||
render(
|
||||
<UserMenuContent
|
||||
user={user}
|
||||
onOpenSupport={vi.fn()}
|
||||
onOpenIntegrationRequests={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const community = screen.getByRole('link', { name: /community/i });
|
||||
const feedback = screen.getByRole('link', { name: /feedback/i });
|
||||
|
|
@ -100,7 +106,13 @@ describe('UserMenuContent', () => {
|
|||
it('triggers the support callback when the support item is clicked', () => {
|
||||
const onOpenSupport = vi.fn();
|
||||
|
||||
render(<UserMenuContent user={user} onOpenSupport={onOpenSupport} />);
|
||||
render(
|
||||
<UserMenuContent
|
||||
user={user}
|
||||
onOpenSupport={onOpenSupport}
|
||||
onOpenIntegrationRequests={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Support'));
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { Link, router, usePage } from '@inertiajs/react';
|
|||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
LogOut,
|
||||
Map,
|
||||
|
|
@ -29,9 +30,14 @@ import {
|
|||
interface UserMenuContentProps {
|
||||
user: User;
|
||||
onOpenSupport: () => void;
|
||||
onOpenIntegrationRequests: () => void;
|
||||
}
|
||||
|
||||
export function UserMenuContent({ user, onOpenSupport }: UserMenuContentProps) {
|
||||
export function UserMenuContent({
|
||||
user,
|
||||
onOpenSupport,
|
||||
onOpenIntegrationRequests,
|
||||
}: UserMenuContentProps) {
|
||||
const cleanup = useMobileNavigation();
|
||||
const { isPrivacyModeEnabled, togglePrivacyMode } = usePrivacyMode();
|
||||
const { version } = usePage<SharedData>().props;
|
||||
|
|
@ -131,6 +137,15 @@ export function UserMenuContent({ user, onOpenSupport }: UserMenuContentProps) {
|
|||
{__('Roadmap')}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onOpenIntegrationRequests();
|
||||
cleanup();
|
||||
}}
|
||||
>
|
||||
<Landmark className="mr-2" />
|
||||
{__('Request integration')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onOpenSupport();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { CashflowSummaryCard } from '@/components/dashboard/cashflow-summary-car
|
|||
import { NetWorthChart as NetWorthChartComponent } from '@/components/dashboard/net-worth-chart';
|
||||
import { TopCategoriesCard } from '@/components/dashboard/top-categories-card';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
|
||||
import UnlockMessageDialog from '@/components/unlock-message-dialog';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import {
|
||||
|
|
@ -41,6 +42,7 @@ interface DashboardProps extends SharedData {
|
|||
current: CashflowSummary;
|
||||
previous: CashflowSummary;
|
||||
};
|
||||
openIntegrationRequests?: boolean;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
|
|
@ -49,6 +51,9 @@ export default function Dashboard() {
|
|||
const { isKeySet, encryptedMessageData, fetchEncryptedMessage } =
|
||||
useEncryptionKey();
|
||||
const [showUnlockDialog, setShowUnlockDialog] = useState(false);
|
||||
const [integrationDrawerOpen, setIntegrationDrawerOpen] = useState(
|
||||
!!props.openIntegrationRequests,
|
||||
);
|
||||
|
||||
const netWorthEvolution = useMemo(
|
||||
() =>
|
||||
|
|
@ -164,6 +169,16 @@ export default function Dashboard() {
|
|||
<AppSidebarLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title={__('Dashboard')} />
|
||||
|
||||
<IntegrationRequestsDrawer
|
||||
open={integrationDrawerOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIntegrationDrawerOpen(open);
|
||||
if (!open) {
|
||||
router.visit(dashboard().url, { preserveScroll: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{encryptedMessageData && (
|
||||
<UnlockMessageDialog
|
||||
open={showUnlockDialog}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Http\Controllers\Auth\VerifyEmailController;
|
|||
use App\Http\Controllers\BudgetController;
|
||||
use App\Http\Controllers\CashflowController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\IntegrationRequestController;
|
||||
use App\Http\Controllers\LoanDetailController;
|
||||
use App\Http\Controllers\OnboardingController;
|
||||
use App\Http\Controllers\OpenBanking\AccountMappingController;
|
||||
|
|
@ -121,10 +122,17 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
Route::post('preview', [RuleSuggestionController::class, 'preview'])->name('preview');
|
||||
Route::post('accept', [RuleSuggestionController::class, 'accept'])->name('accept');
|
||||
});
|
||||
|
||||
// Integration requests — community board to propose and vote on bank integrations.
|
||||
Route::get('integration-requests/data', [IntegrationRequestController::class, 'data'])->name('integration-requests.data');
|
||||
Route::post('integration-requests', [IntegrationRequestController::class, 'store'])->name('integration-requests.store');
|
||||
Route::post('integration-requests/{integrationRequest}/vote', [IntegrationRequestController::class, 'vote'])->name('integration-requests.vote');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
|
||||
Route::get('dashboard', DashboardController::class)->name('dashboard');
|
||||
// Renders the dashboard with the integration-requests drawer opened on top.
|
||||
Route::get('integration-requests', [IntegrationRequestController::class, 'index'])->name('integration-requests.index');
|
||||
Route::get('cashflow', CashflowController::class)->name('cashflow');
|
||||
|
||||
Route::get('accounts', [AccountController::class, 'index'])->name('accounts.list');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\IntegrationRequestStatus;
|
||||
use App\Models\IntegrationRequest;
|
||||
use App\Models\User;
|
||||
|
||||
test('guests cannot access the integration requests page', function () {
|
||||
$this->get('/integration-requests')->assertRedirect();
|
||||
});
|
||||
|
||||
test('the integration requests url renders the dashboard with the drawer open', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/integration-requests')
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('dashboard')
|
||||
->where('openIntegrationRequests', true)
|
||||
);
|
||||
});
|
||||
|
||||
test('the data endpoint returns the board state as json', function () {
|
||||
$user = User::factory()->create();
|
||||
IntegrationRequest::factory()->approved()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/integration-requests/data')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'requests')
|
||||
->assertJsonPath('actionsRemaining', 3);
|
||||
});
|
||||
|
||||
test('creating a request auto-votes it and costs two actions', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/integration-requests', [
|
||||
'name' => 'Revolut',
|
||||
'url' => 'https://revolut.com',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('actionsRemaining', 1)
|
||||
->assertJsonPath('requests.0.votes_count', 1)
|
||||
->assertJsonPath('requests.0.has_voted', true);
|
||||
|
||||
$this->assertDatabaseHas('integration_requests', [
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Revolut',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
$this->assertDatabaseCount('integration_request_votes', 1);
|
||||
});
|
||||
|
||||
test('a user cannot create a request with only one action left', function () {
|
||||
$user = User::factory()->create();
|
||||
IntegrationRequest::factory()->count(2)->create(['user_id' => $user->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/integration-requests', ['name' => 'X', 'url' => 'https://x.com'])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('requesting an integration requires a name and a valid url', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/integration-requests', ['name' => '', 'url' => 'not-a-url'])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['name', 'url']);
|
||||
});
|
||||
|
||||
test('a user can vote and then remove the vote', function () {
|
||||
$user = User::factory()->create();
|
||||
$request = IntegrationRequest::factory()->approved()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/integration-requests/{$request->id}/vote")
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('integration_request_votes', [
|
||||
'integration_request_id' => $request->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/integration-requests/{$request->id}/vote")
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseMissing('integration_request_votes', [
|
||||
'integration_request_id' => $request->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('a user cannot exceed the monthly action limit', function () {
|
||||
$user = User::factory()->create();
|
||||
IntegrationRequest::factory()->count(3)->create(['user_id' => $user->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/integration-requests', ['name' => 'X', 'url' => 'https://x.com'])
|
||||
->assertStatus(422);
|
||||
|
||||
$other = IntegrationRequest::factory()->approved()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/integration-requests/{$other->id}/vote")
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('removing a vote is allowed even at the monthly limit', function () {
|
||||
$user = User::factory()->create();
|
||||
$request = IntegrationRequest::factory()->approved()->create();
|
||||
|
||||
IntegrationRequest::factory()->count(2)->create(['user_id' => $user->id]);
|
||||
$request->votes()->create(['user_id' => $user->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/integration-requests/{$request->id}/vote")
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseMissing('integration_request_votes', [
|
||||
'integration_request_id' => $request->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('pending requests are only visible to their creator', function () {
|
||||
$owner = User::factory()->create();
|
||||
$viewer = User::factory()->create();
|
||||
IntegrationRequest::factory()->create(['user_id' => $owner->id]);
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->getJson('/integration-requests/data')
|
||||
->assertJsonCount(0, 'requests');
|
||||
|
||||
$this->actingAs($owner)
|
||||
->getJson('/integration-requests/data')
|
||||
->assertJsonCount(1, 'requests');
|
||||
});
|
||||
|
||||
test('approved requests are visible to every user', function () {
|
||||
IntegrationRequest::factory()->approved()->create();
|
||||
|
||||
$this->actingAs(User::factory()->create())
|
||||
->getJson('/integration-requests/data')
|
||||
->assertJsonCount(1, 'requests');
|
||||
});
|
||||
|
||||
test('a user cannot vote on a pending request they do not own', function () {
|
||||
$request = IntegrationRequest::factory()->create();
|
||||
|
||||
$this->actingAs(User::factory()->create())
|
||||
->postJson("/integration-requests/{$request->id}/vote")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('the review command approves a pending request', function () {
|
||||
$request = IntegrationRequest::factory()->create(['name' => 'Revolut']);
|
||||
|
||||
$this->artisan('integration-requests:review')
|
||||
->expectsChoice(
|
||||
"Review \"{$request->name}\" ({$request->url})",
|
||||
'approve',
|
||||
['approve', 'reject', 'skip'],
|
||||
)
|
||||
->assertSuccessful();
|
||||
|
||||
expect($request->fresh()->status)->toBe(IntegrationRequestStatus::Approved);
|
||||
});
|
||||
|
||||
test('the review command rejects a pending request', function () {
|
||||
$request = IntegrationRequest::factory()->create(['name' => 'Spam']);
|
||||
|
||||
$this->artisan('integration-requests:review')
|
||||
->expectsChoice(
|
||||
"Review \"{$request->name}\" ({$request->url})",
|
||||
'reject',
|
||||
['approve', 'reject', 'skip'],
|
||||
)
|
||||
->assertSuccessful();
|
||||
|
||||
expect($request->fresh()->status)->toBe(IntegrationRequestStatus::Rejected);
|
||||
});
|
||||
|
||||
test('the review command reports when there is nothing to review', function () {
|
||||
$this->artisan('integration-requests:review')
|
||||
->expectsOutput('No pending integration requests.')
|
||||
->assertSuccessful();
|
||||
});
|
||||
Loading…
Reference in New Issue