diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d64b8f..4cc17d35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Accounts view** (PRD §6.4, PRD-v2 §P1.4, task 23): full Accounts management UI replacing the previous `PlaceholderView`. Header tabs (`All` / `Debrid` / `Premium` / `Free`) drive a category filter on top of the SQLite-backed `account_list` query, with the `(filter, all)` count rendered next to each label. Each row exposes the service, username, account type, derived status badge (`Active` / `Expired` / `Disabled` / `Unverified`), an aria-labelled traffic progress bar (used / total formatted via `formatBytes`), `valid_until` and `last_validated` columns, an enable/disable `Switch`, an inline `Validate` button, and a kebab menu with `Edit` / `Delete`. The new `AddAccountDialog` validates non-empty service / username / password before submission. `EditAccountDialog` posts a partial `AccountPatch` (skips fields that did not change so the keyring rotation only fires when the password field is filled). The `Delete` action honours the existing `settings.confirm_delete` toggle: when enabled it pops the new `DeleteAccountDialog` (translated description naming the row), otherwise it deletes immediately. `ImportAccountsDialog` calls `tauri-plugin-dialog`'s file-pick to anchor the encrypted bundle path, prompts for the passphrase, then calls `account_import` and invalidates the list cache so freshly-imported rows appear without a manual refresh; `ExportAccountsDialog` requires the user to confirm the passphrase, opens the native `save` dialog for the destination, and reports the row count via toast. Nine new Tauri IPC commands wire the existing `CommandBus` / `QueryBus` handlers (tasks 21, 22) to the frontend: `account_add`, `account_update`, `account_delete`, `account_validate`, `account_export`, `account_import`, `account_list`, `account_get`, `account_traffic_get`, all registered in `invoke_handler!` and re-exported from `lib.rs`. The runtime now wires `SqliteAccountRepo` to both buses and provides the `KeyringAccountStore` + `AesGcmPbkdf2Codec` adapters to the `CommandBus`. Adds `useAccountsQuery` (TanStack Query, 30 s `staleTime`) and `accountQueries` cache key factory. New i18n namespace `accounts.*` covers titles, status badges, dialog copy and toast messages in `en.json` + `fr.json`. 13 Vitest tests cover render, empty state, category filter, add → IPC → toast flow, delete → confirm → IPC, export trigger disabled when no accounts, export with passphrase, import with file picker. `AccountValidator` is intentionally not wired in this commit — `account_validate` returns the configured `Validation` error until the first hoster plugin lands (task 38), letting the UI render the failure toast without crashing. The "volume per account" stat from the requirements list is deferred until `history` gains an `account_id` column. - **Accounts queries** (PRD §6.4, PRD-v2 §P1.3, task 22): three CQRS query handlers (`list_accounts`, `get_account`, `get_account_traffic`) wired through the `QueryBus` builder via a new `with_account_repo` setter. New read models `AccountViewDto` and `AccountTrafficDto` (`#[serde(rename_all = "camelCase")]`) expose every persisted field — `id`, `service_name`, `username`, `account_type`, `enabled`, `traffic_left`, `traffic_total`, `valid_until`, `last_validated`, `created_at`, `credential_ref` — and never carry a password or raw credential field, by construction. `AccountFilter { service_name?, account_type?, enabled? }` AND-combines filters: `service_name` is delegated to the repo's `list_by_service` for SQL-level pruning, while `account_type` and `enabled` filter in memory. `get_account_traffic` returns the persisted counters; the upstream-refresh path is the existing `account_validate` command (task 21), keeping queries side-effect free per the project CQRS rule. 21 new unit tests against an `InMemoryAccountRepoForQueries` fixture cover filter combinations, missing-id 404s, missing-repo validation errors, camelCase serialization shape, and explicit "no password field" assertions on `serde_json::to_value` output. Unblocks task 23 (Vue Accounts). - **Accounts commands** (PRD §6.4, PRD-v2 §P1.2, task 21): six application-layer command handlers (`add_account`, `update_account`, `delete_account`, `validate_account`, `export_accounts`, `import_accounts`) wired through the `CommandBus` builder. New driven ports `AccountCredentialStore`, `AccountValidator` (with `ValidationOutcome`) and `PassphraseCodec` keep handlers free of plugin / crypto dependencies. `KeyringAccountStore` adapter persists per-account passwords under `vortex-account-{id}` keyring entries; `AesGcmPbkdf2Codec` adapter implements the import / export bundle format using AES-256-GCM with a PBKDF2-HMAC-SHA256 200 000-iteration KDF, fresh per-call salt + nonce, header bound as AAD, and a `VORTACC` magic + version byte so tampered or downgraded bundles fail authentication. Domain events `AccountAdded`, `AccountUpdated`, `AccountDeleted`, `AccountValidated`, `AccountValidationFailed`, `AccountsImported`, `AccountsExported` published via `EventBus` and forwarded by the Tauri bridge as `account-*` browser events. Add rolls back the SQLite row when the keyring write fails so credentials never end up orphaned; import validates every entry up-front and skips `(service_name, username)` pairs already present without inserting partial state. Unblocks task 23 (Vue Accounts). - **Accounts persistence** (PRD §6.4, PRD-v2 §P1.1, task 20): SQLite `accounts` table (migration `m20260428_000006`) with `id` / `service_name` / `username` / `account_type` / `enabled` / `traffic_left` / `traffic_total` / `valid_until` / `last_validated` / `created_at` columns and a UNIQUE `(service_name, username)` index. New `AccountRepository` driven port (`save` / `find_by_id` / `list` / `list_by_service` / `delete`) and `SqliteAccountRepo` adapter with sea-orm entity + `from_domain` / `into_domain` converters. UNIQUE violations surface as `DomainError::AlreadyExists` instead of leaking storage errors. Domain `Account` aggregate gained `traffic_total`, `last_validated`, `created_at` fields and switched its identifier to `AccountId(String)` so generated account ids match the spec's `TEXT PRIMARY KEY`. `Account::credential_ref()` returns a `keyring://{service}/{username}` URI exposing a logical reference suitable for diagnostics; passwords themselves are never persisted to SQLite — they live in the OS keychain via the `AccountCredentialStore` adapter (added in task 21, keyed by `AccountId`). Unblocks tasks 21-25, 38, 51-56, 75-76. diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ab597955..85776dd7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,7 @@ "notification:allow-notify", "notification:allow-request-permission", "notification:allow-is-permission-granted", - "dialog:allow-save" + "dialog:allow-save", + "dialog:allow-open" ] } diff --git a/src-tauri/src/adapters/driving/tauri_ipc.rs b/src-tauri/src/adapters/driving/tauri_ipc.rs index e467fd2f..0822601c 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -13,24 +13,29 @@ use crate::adapters::driven::logging::download_log_store::DownloadLogStore; use crate::application::command_bus::CommandBus; use crate::application::commands::store_install::{StoreInstallCommand, StoreUpdateCommand}; use crate::application::commands::{ - CancelDownloadCommand, ChangeDirectoryBulkCommand, ChangeDirectoryBulkOutcome, - ChangeDirectoryCommand, ChangeDirectoryFailure, ClearDownloadsByStateCommand, - ClearHistoryCommand, DeleteHistoryEntryCommand, DisablePluginCommand, EnablePluginCommand, - ExportHistoryCommand, ExportHistoryFormat, InstallPluginCommand, MoveToBottomCommand, - MoveToTopCommand, OpenDownloadFileCommand, OpenDownloadFolderCommand, PauseAllDownloadsCommand, + AccountPatch, AddAccountCommand, CancelDownloadCommand, ChangeDirectoryBulkCommand, + ChangeDirectoryBulkOutcome, ChangeDirectoryCommand, ChangeDirectoryFailure, + ClearDownloadsByStateCommand, ClearHistoryCommand, DeleteAccountCommand, + DeleteHistoryEntryCommand, DisablePluginCommand, EnablePluginCommand, ExportAccountsCommand, + ExportAccountsOutcome, ExportHistoryCommand, ExportHistoryFormat, ImportAccountsCommand, + ImportAccountsOutcome, InstallPluginCommand, MoveToBottomCommand, MoveToTopCommand, + OpenDownloadFileCommand, OpenDownloadFolderCommand, PauseAllDownloadsCommand, PauseDownloadCommand, PurgeHistoryCommand, RedownloadCommand, RedownloadSource, RemoveDownloadCommand, ReorderQueueCommand, ReportBrokenPluginCommand, ResolveLinksCommand, ResolvedLinkDto, ResumeAllDownloadsCommand, ResumeDownloadCommand, RetryDownloadCommand, - SetPriorityCommand, StartDownloadCommand, UninstallPluginCommand, UpdateConfigCommand, - UpdatePluginConfigCommand, VerifyChecksumCommand, VerifyChecksumOutcome, + SetPriorityCommand, StartDownloadCommand, UninstallPluginCommand, UpdateAccountCommand, + UpdateConfigCommand, UpdatePluginConfigCommand, ValidateAccountCommand, ValidationOutcomeDto, + VerifyChecksumCommand, VerifyChecksumOutcome, }; use crate::application::error::AppError; use crate::application::queries::{ - CountDownloadsByStateQuery, GetDownloadDetailQuery, GetDownloadsQuery, GetHistoryEntryQuery, - GetPluginConfigQuery, GetStatsQuery, ListHistoryQuery, ListPluginsQuery, SearchHistoryQuery, + AccountFilter, CountDownloadsByStateQuery, GetAccountQuery, GetAccountTrafficQuery, + GetDownloadDetailQuery, GetDownloadsQuery, GetHistoryEntryQuery, GetPluginConfigQuery, + GetStatsQuery, ListAccountsQuery, ListHistoryQuery, ListPluginsQuery, SearchHistoryQuery, TopModulesQuery, }; use crate::application::query_bus::QueryBus; +use crate::application::read_models::account_view::{AccountTrafficDto, AccountViewDto}; use crate::application::read_models::download_detail_view::DownloadDetailViewDto; use crate::application::read_models::download_view::DownloadViewDto; use crate::application::read_models::history_view::HistoryViewDto; @@ -38,6 +43,7 @@ use crate::application::read_models::plugin_config_view::PluginConfigView; use crate::application::read_models::plugin_store_view::PluginStoreEntryDto; use crate::application::read_models::plugin_view::PluginViewDto; use crate::application::read_models::stats_view::{ModuleStatsDto, StatsViewDto}; +use crate::domain::model::account::{AccountId, AccountType}; use crate::domain::model::config::{AppConfig, ConfigPatch}; use crate::domain::model::download::{DownloadId, DownloadState}; use crate::domain::model::views::{ @@ -2593,6 +2599,260 @@ pub async fn history_purge_older_than( .map_err(|e| e.to_string()) } +// ── Accounts ──────────────────────────────────────────────────────── + +fn parse_account_type_arg(raw: &str) -> Result { + raw.parse::().map_err(|e| e.to_string()) +} + +fn now_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Patch payload mirrored from the frontend. Each field is optional and +/// `None` leaves the persisted account unchanged. +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountPatchDto { + pub username: Option, + pub password: Option, + pub account_type: Option, + pub enabled: Option, +} + +impl AccountPatchDto { + fn into_domain(self) -> Result { + let account_type = match self.account_type { + Some(raw) => Some(parse_account_type_arg(&raw)?), + None => None, + }; + Ok(AccountPatch { + username: self.username, + password: self.password, + account_type, + enabled: self.enabled, + }) + } +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidationOutcomeView { + pub valid: bool, + pub latency_ms: Option, + pub traffic_left: Option, + pub traffic_total: Option, + pub valid_until: Option, + pub error_message: Option, +} + +impl From for ValidationOutcomeView { + fn from(o: ValidationOutcomeDto) -> Self { + Self { + valid: o.valid, + latency_ms: o.latency_ms, + traffic_left: o.traffic_left, + traffic_total: o.traffic_total, + valid_until: o.valid_until, + error_message: o.error_message, + } + } +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportAccountsView { + pub path: String, + pub count: u32, +} + +impl From for ExportAccountsView { + fn from(o: ExportAccountsOutcome) -> Self { + Self { + path: o.path.display().to_string(), + count: o.count, + } + } +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportAccountsView { + pub path: String, + pub imported: u32, + pub skipped_duplicates: u32, +} + +impl From for ImportAccountsView { + fn from(o: ImportAccountsOutcome) -> Self { + Self { + path: o.path.display().to_string(), + imported: o.imported, + skipped_duplicates: o.skipped_duplicates, + } + } +} + +#[tauri::command] +pub async fn account_add( + state: State<'_, AppState>, + service_name: String, + username: String, + password: String, + account_type: String, +) -> Result { + let account_type = parse_account_type_arg(&account_type)?; + state + .command_bus + .handle_add_account(AddAccountCommand { + service_name, + username, + password, + account_type, + created_at_ms: now_unix_ms(), + }) + .await + .map(|id| id.as_str().to_string()) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn account_update( + state: State<'_, AppState>, + id: String, + patch: AccountPatchDto, +) -> Result<(), String> { + let patch = patch.into_domain()?; + state + .command_bus + .handle_update_account(UpdateAccountCommand { + id: AccountId::new(id), + patch, + }) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn account_delete(state: State<'_, AppState>, id: String) -> Result<(), String> { + state + .command_bus + .handle_delete_account(DeleteAccountCommand { + id: AccountId::new(id), + }) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn account_validate( + state: State<'_, AppState>, + id: String, +) -> Result { + state + .command_bus + .handle_validate_account(ValidateAccountCommand { + id: AccountId::new(id), + now_ms: now_unix_ms(), + }) + .await + .map(ValidationOutcomeView::from) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn account_export( + state: State<'_, AppState>, + path: String, + passphrase: String, +) -> Result { + state + .command_bus + .handle_export_accounts(ExportAccountsCommand { + path: PathBuf::from(path), + passphrase, + }) + .await + .map(ExportAccountsView::from) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn account_import( + state: State<'_, AppState>, + path: String, + passphrase: String, +) -> Result { + state + .command_bus + .handle_import_accounts(ImportAccountsCommand { + path: PathBuf::from(path), + passphrase, + now_ms: now_unix_ms(), + }) + .await + .map(ImportAccountsView::from) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn account_list( + state: State<'_, AppState>, + service_name: Option, + account_type: Option, + enabled: Option, +) -> Result, String> { + let service_name = service_name + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let account_type = match account_type { + Some(raw) => Some(parse_account_type_arg(&raw)?), + None => None, + }; + let filter = if service_name.is_none() && account_type.is_none() && enabled.is_none() { + None + } else { + Some(AccountFilter { + service_name, + account_type, + enabled, + }) + }; + state + .query_bus + .handle_list_accounts(ListAccountsQuery { filter }) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn account_get(state: State<'_, AppState>, id: String) -> Result { + state + .query_bus + .handle_get_account(GetAccountQuery { + id: AccountId::new(id), + }) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn account_traffic_get( + state: State<'_, AppState>, + id: String, +) -> Result { + state + .query_bus + .handle_get_account_traffic(GetAccountTrafficQuery { + id: AccountId::new(id), + }) + .await + .map_err(|e| e.to_string()) +} + #[cfg(test)] mod tests { use super::{ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f112e118..3d4b7a1c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,9 +7,10 @@ use std::sync::Arc; use tauri::Manager; use domain::ports::driven::{ - ArchiveExtractor, ClipboardObserver, Clock, ConfigStore, CredentialStore, DownloadEngine, - DownloadReadRepository, DownloadRepository, EventBus, FileStorage, HistoryRepository, - HttpClient, PluginLoader, PluginReadRepository, StatsRepository, + AccountCredentialStore, AccountRepository, ArchiveExtractor, ClipboardObserver, Clock, + ConfigStore, CredentialStore, DownloadEngine, DownloadReadRepository, DownloadRepository, + EventBus, FileStorage, HistoryRepository, HttpClient, PassphraseCodec, PluginLoader, + PluginReadRepository, StatsRepository, }; // Public API — concrete types for app wiring (main.rs, Tauri setup, integration tests) @@ -67,12 +68,13 @@ pub use application::services::backfill_history_for_completed_downloads; pub use domain::model::ExtractionConfig; pub use adapters::driving::tauri_ipc::{ - self, AppState, browse_file, browse_folder, clipboard_state, clipboard_toggle, - command_get_media_metadata, download_cancel, download_change_directory, - download_change_directory_bulk, download_clear_completed, download_clear_failed, - download_count_by_state, download_detail, download_list, download_logs, download_media_start, - download_move_to_bottom, download_move_to_top, download_open_file, download_open_folder, - download_pause, download_pause_all, download_redownload, download_remove, + self, AppState, account_add, account_delete, account_export, account_get, account_import, + account_list, account_traffic_get, account_update, account_validate, browse_file, + browse_folder, clipboard_state, clipboard_toggle, command_get_media_metadata, download_cancel, + download_change_directory, download_change_directory_bulk, download_clear_completed, + download_clear_failed, download_count_by_state, download_detail, download_list, download_logs, + download_media_start, download_move_to_bottom, download_move_to_top, download_open_file, + download_open_folder, download_pause, download_pause_all, download_redownload, download_remove, download_reorder_queue, download_resume, download_resume_all, download_retry, download_set_priority, download_start, download_verify_checksum, history_clear, history_delete_entry, history_export, history_get_by_id, history_list, @@ -139,6 +141,9 @@ pub fn run() { Some(uuid::Uuid::new_v4().to_string()), )); let credential_store: Arc = Arc::new(KeyringCredentialStore); + let account_credential_store: Arc = + Arc::new(KeyringAccountStore); + let passphrase_codec: Arc = Arc::new(AesGcmPbkdf2Codec::new()); let clipboard_observer: Arc = Arc::new(TauriClipboardObserver::new(app_handle.clone())); let archive_extractor: Arc = @@ -154,6 +159,8 @@ pub fn run() { let history_repo: Arc = Arc::new(SqliteHistoryRepo::new(db.clone())); let stats_repo: Arc = Arc::new(SqliteStatsRepo::new(db.clone())); + let account_repo: Arc = + Arc::new(SqliteAccountRepo::new(db.clone())); // ── Plugin system ─────────────────────────────────────── let shared_resources = Arc::new(SharedHostResources::new()); @@ -359,7 +366,10 @@ pub fn run() { .with_checksum_computer(checksum_computer) .with_file_opener(file_opener) .with_url_opener(url_opener) - .with_plugin_config_store(plugin_config_store.clone()), + .with_plugin_config_store(plugin_config_store.clone()) + .with_account_repo(account_repo.clone()) + .with_account_credential_store(account_credential_store) + .with_passphrase_codec(passphrase_codec), ); // Stats recorder bridge keeps its own handle once the query @@ -374,7 +384,8 @@ pub fn run() { archive_extractor, ) .with_plugin_loader(plugin_loader.clone()) - .with_plugin_config_store(plugin_config_store), + .with_plugin_config_store(plugin_config_store) + .with_account_repo(account_repo), ); // ── Register AppState ─────────────────────────────────── @@ -546,6 +557,15 @@ pub fn run() { stats_top_modules, browse_folder, browse_file, + account_add, + account_update, + account_delete, + account_validate, + account_export, + account_import, + account_list, + account_get, + account_traffic_get, ]) .run(tauri::generate_context!()) // Tauri's run() has no meaningful recovery path — panic is intentional here diff --git a/src/api/queries.ts b/src/api/queries.ts index c2131a53..c1c52b78 100644 --- a/src/api/queries.ts +++ b/src/api/queries.ts @@ -1,3 +1,4 @@ +import type { AccountListFilter } from '@/types/account'; import type { DownloadFilter } from '@/types/download'; export const downloadQueries = { @@ -26,3 +27,13 @@ export const statsQueries = { all: () => ['stats'] as const, overview: () => [...statsQueries.all(), 'overview'] as const, }; + +export const accountQueries = { + all: () => ['accounts'] as const, + lists: () => [...accountQueries.all(), 'list'] as const, + list: (filter?: AccountListFilter) => + filter ? ([...accountQueries.lists(), filter] as const) : (accountQueries.lists() as readonly unknown[]), + details: () => [...accountQueries.all(), 'detail'] as const, + detail: (id: string) => [...accountQueries.details(), id] as const, + traffic: (id: string) => [...accountQueries.all(), 'traffic', id] as const, +}; diff --git a/src/hooks/useAccountsQuery.ts b/src/hooks/useAccountsQuery.ts new file mode 100644 index 00000000..4ebc769e --- /dev/null +++ b/src/hooks/useAccountsQuery.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { tauriInvoke } from '@/api/client'; +import { accountQueries } from '@/api/queries'; +import type { AccountListFilter, AccountView } from '@/types/account'; + +export function useAccountsQuery(filter?: AccountListFilter) { + return useQuery({ + queryKey: filter ? accountQueries.list(filter) : accountQueries.lists(), + queryFn: () => + tauriInvoke('account_list', { + serviceName: filter?.serviceName, + accountType: filter?.accountType, + enabled: filter?.enabled, + }), + staleTime: 30_000, + }); +} diff --git a/src/i18n/__tests__/issue30-ui-fr.test.tsx b/src/i18n/__tests__/issue30-ui-fr.test.tsx index 235bae53..76e60913 100644 --- a/src/i18n/__tests__/issue30-ui-fr.test.tsx +++ b/src/i18n/__tests__/issue30-ui-fr.test.tsx @@ -129,7 +129,6 @@ describe("issue #30 — French UI translations", () => { it("renders placeholder views in French", () => { const views = [ { component: , title: "Paquets" }, - { component: , title: "Comptes" }, { component: , title: "Captcha" }, { component: , title: "Planificateur" }, ]; @@ -142,6 +141,12 @@ describe("issue #30 — French UI translations", () => { } }); + it("renders the Accounts view header in French", () => { + mockInvoke.mockResolvedValueOnce([]); + renderWithProviders(); + expect(screen.getByRole("heading", { name: "Comptes" })).toBeInTheDocument(); + }); + it("renders plugin store catalogue shell in French", () => { mockInvoke.mockResolvedValue([]); renderWithProviders(); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a023e063..991f9282 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -502,5 +502,106 @@ "countLabel": "downloads", "hint": "Top 5 modules ranked by download count, independent of the selected period" } + }, + "accounts": { + "title": "Accounts", + "loading": "Loading accounts…", + "empty": "No accounts configured yet", + "filter": { + "all": "All", + "debrid": "Debrid", + "premium": "Premium", + "free": "Free" + }, + "columns": { + "service": "Service", + "username": "Username", + "type": "Type", + "status": "Status", + "traffic": "Traffic", + "validUntil": "Valid until", + "lastValidated": "Last validated", + "actions": "Actions" + }, + "status": { + "active": "Active", + "expired": "Expired", + "disabled": "Disabled", + "unverified": "Unverified" + }, + "traffic": { + "ariaLabel": "Traffic remaining", + "format": "{{used}} / {{total}}", + "unknown": "Unknown" + }, + "validUntil": { + "never": "—" + }, + "actions": { + "add": "Add account", + "validate": "Validate", + "edit": "Edit", + "delete": "Delete", + "more": "More actions", + "import": "Import…", + "export": "Export…" + }, + "addDialog": { + "title": "Add account", + "description": "Credentials are stored in your OS keyring, never in plain text on disk.", + "service": "Service", + "servicePlaceholder": "real-debrid", + "username": "Username", + "password": "Password", + "type": "Type", + "typeHint": "Pick a category to surface it in the right tab.", + "submit": "Add", + "cancel": "Cancel" + }, + "deleteDialog": { + "title": "Delete account?", + "description": "{{username}} on {{service}} will be removed and the keyring credential wiped. This cannot be undone.", + "confirm": "Delete", + "cancel": "Cancel" + }, + "importDialog": { + "title": "Import accounts", + "description": "Pick the encrypted bundle and enter the passphrase used at export time.", + "filePath": "File", + "browse": "Browse…", + "passphrase": "Passphrase", + "submit": "Import", + "cancel": "Cancel" + }, + "exportDialog": { + "title": "Export accounts", + "description": "Choose where to save the encrypted bundle and a passphrase to protect it.", + "passphrase": "Passphrase", + "passphraseConfirm": "Confirm passphrase", + "passphraseMismatch": "Passphrases do not match", + "submit": "Export", + "cancel": "Cancel" + }, + "toast": { + "addSuccess": "Account added", + "addError": "Failed to add account", + "updateSuccess": "Account updated", + "updateError": "Failed to update account", + "deleteSuccess": "Account deleted", + "deleteError": "Failed to delete account", + "validateSuccess": "Account is valid", + "validateInvalid": "Validation failed: {{reason}}", + "validateError": "Validation request failed", + "exportSuccess_one": "{{count}} account exported", + "exportSuccess_other": "{{count}} accounts exported", + "exportError": "Export failed", + "importSuccess_one": "{{count}} account imported", + "importSuccess_other": "{{count}} accounts imported", + "importSkipped_one": "{{count}} duplicate skipped", + "importSkipped_other": "{{count}} duplicates skipped", + "importError": "Import failed", + "missingFile": "Pick a file first", + "missingPassphrase": "Passphrase required" + } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2fed5f69..c1b0fd0c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -502,5 +502,106 @@ "countLabel": "téléchargements", "hint": "Top 5 modules par nombre d'utilisations, indépendant de la période sélectionnée" } + }, + "accounts": { + "title": "Comptes", + "loading": "Chargement des comptes…", + "empty": "Aucun compte configuré pour le moment", + "filter": { + "all": "Tous", + "debrid": "Debrid", + "premium": "Premium", + "free": "Gratuit" + }, + "columns": { + "service": "Service", + "username": "Identifiant", + "type": "Type", + "status": "Statut", + "traffic": "Trafic", + "validUntil": "Valide jusqu'au", + "lastValidated": "Dernière validation", + "actions": "Actions" + }, + "status": { + "active": "Actif", + "expired": "Expiré", + "disabled": "Désactivé", + "unverified": "Non vérifié" + }, + "traffic": { + "ariaLabel": "Trafic restant", + "format": "{{used}} / {{total}}", + "unknown": "Inconnu" + }, + "validUntil": { + "never": "—" + }, + "actions": { + "add": "Ajouter un compte", + "validate": "Valider", + "edit": "Modifier", + "delete": "Supprimer", + "more": "Autres actions", + "import": "Importer…", + "export": "Exporter…" + }, + "addDialog": { + "title": "Ajouter un compte", + "description": "Les identifiants sont stockés dans le trousseau de l'OS, jamais en clair sur le disque.", + "service": "Service", + "servicePlaceholder": "real-debrid", + "username": "Identifiant", + "password": "Mot de passe", + "type": "Type", + "typeHint": "Choisissez la catégorie pour le ranger dans le bon onglet.", + "submit": "Ajouter", + "cancel": "Annuler" + }, + "deleteDialog": { + "title": "Supprimer le compte ?", + "description": "{{username}} sur {{service}} sera retiré et l'identifiant trousseau effacé. Action irréversible.", + "confirm": "Supprimer", + "cancel": "Annuler" + }, + "importDialog": { + "title": "Importer des comptes", + "description": "Sélectionnez le bundle chiffré et entrez la passphrase utilisée à l'export.", + "filePath": "Fichier", + "browse": "Parcourir…", + "passphrase": "Passphrase", + "submit": "Importer", + "cancel": "Annuler" + }, + "exportDialog": { + "title": "Exporter les comptes", + "description": "Choisissez la destination du bundle chiffré et une passphrase pour le protéger.", + "passphrase": "Passphrase", + "passphraseConfirm": "Confirmer la passphrase", + "passphraseMismatch": "Les passphrases ne correspondent pas", + "submit": "Exporter", + "cancel": "Annuler" + }, + "toast": { + "addSuccess": "Compte ajouté", + "addError": "Échec de l'ajout du compte", + "updateSuccess": "Compte mis à jour", + "updateError": "Échec de la mise à jour", + "deleteSuccess": "Compte supprimé", + "deleteError": "Échec de la suppression", + "validateSuccess": "Compte valide", + "validateInvalid": "Validation échouée : {{reason}}", + "validateError": "La requête de validation a échoué", + "exportSuccess_one": "{{count}} compte exporté", + "exportSuccess_other": "{{count}} comptes exportés", + "exportError": "L'export a échoué", + "importSuccess_one": "{{count}} compte importé", + "importSuccess_other": "{{count}} comptes importés", + "importSkipped_one": "{{count}} doublon ignoré", + "importSkipped_other": "{{count}} doublons ignorés", + "importError": "L'import a échoué", + "missingFile": "Sélectionnez un fichier", + "missingPassphrase": "Passphrase requise" + } } } diff --git a/src/types/account.ts b/src/types/account.ts new file mode 100644 index 00000000..ebbdd580 --- /dev/null +++ b/src/types/account.ts @@ -0,0 +1,63 @@ +export type AccountType = 'free' | 'premium' | 'debrid'; + +export interface AccountView { + id: string; + serviceName: string; + username: string; + accountType: AccountType; + enabled: boolean; + trafficLeft: number | null; + trafficTotal: number | null; + validUntil: number | null; + lastValidated: number | null; + createdAt: number; + credentialRef: string; +} + +export interface AccountTraffic { + id: string; + trafficLeft: number | null; + trafficTotal: number | null; + validUntil: number | null; + lastValidated: number | null; +} + +export interface AccountPatch { + username?: string; + password?: string; + accountType?: AccountType; + enabled?: boolean; +} + +export interface AddAccountInput { + serviceName: string; + username: string; + password: string; + accountType: AccountType; +} + +export interface ValidationOutcome { + valid: boolean; + latencyMs: number | null; + trafficLeft: number | null; + trafficTotal: number | null; + validUntil: number | null; + errorMessage: string | null; +} + +export interface ExportAccountsResult { + path: string; + count: number; +} + +export interface ImportAccountsResult { + path: string; + imported: number; + skippedDuplicates: number; +} + +export interface AccountListFilter { + serviceName?: string; + accountType?: AccountType; + enabled?: boolean; +} diff --git a/src/views/AccountsView.tsx b/src/views/AccountsView.tsx deleted file mode 100644 index 0c6b890d..00000000 --- a/src/views/AccountsView.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { User } from "lucide-react"; -import { PlaceholderView } from "./PlaceholderView"; - -export function AccountsView() { - return ; -} diff --git a/src/views/AccountsView/AccountList.tsx b/src/views/AccountsView/AccountList.tsx new file mode 100644 index 00000000..1a884ed5 --- /dev/null +++ b/src/views/AccountsView/AccountList.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from "react-i18next"; +import type { AccountView } from "@/types/account"; +import { AccountRow, type AccountRowActions } from "./AccountRow"; + +interface AccountListProps { + accounts: AccountView[]; + actions: AccountRowActions; + validatingIds: ReadonlySet; +} + +export function AccountList({ accounts, actions, validatingIds }: AccountListProps) { + const { t } = useTranslation(); + + if (accounts.length === 0) { + return ( +
+ {t("accounts.empty")} +
+ ); + } + + return ( +
+ + + + + + + + + + + + + + + {accounts.map((account) => ( + + ))} + +
{t("accounts.columns.service")}{t("accounts.columns.username")}{t("accounts.columns.type")}{t("accounts.columns.status")}{t("accounts.columns.traffic")}{t("accounts.columns.validUntil")}{t("accounts.columns.lastValidated")}{t("accounts.columns.actions")}
+
+ ); +} diff --git a/src/views/AccountsView/AccountRow.tsx b/src/views/AccountsView/AccountRow.tsx new file mode 100644 index 00000000..938cea42 --- /dev/null +++ b/src/views/AccountsView/AccountRow.tsx @@ -0,0 +1,138 @@ +import { useTranslation } from "react-i18next"; +import { MoreHorizontal } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { Switch } from "@/components/ui/switch"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useLanguage } from "@/hooks/useLanguage"; +import { formatBytes, formatDate } from "@/lib/format"; +import type { AccountView } from "@/types/account"; +import { deriveAccountStatus, type AccountStatus } from "./statusUtils"; + +export interface AccountRowActions { + validate: (account: AccountView) => void; + edit: (account: AccountView) => void; + delete: (account: AccountView) => void; + toggleEnabled: (account: AccountView, nextEnabled: boolean) => void; +} + +interface AccountRowProps { + account: AccountView; + actions: AccountRowActions; + validating?: boolean; +} + +const STATUS_VARIANT: Record = { + active: "default", + expired: "destructive", + disabled: "secondary", + unverified: "outline", +}; + +export function AccountRow({ account, actions, validating }: AccountRowProps) { + const { t } = useTranslation(); + const { current: language } = useLanguage(); + const status = deriveAccountStatus(account); + const trafficPercent = computeTrafficPercent(account.trafficLeft, account.trafficTotal); + + return ( + + {account.serviceName} + + {account.username} + + + {t(`accounts.filter.${account.accountType}`)} + + + + {t(`accounts.status.${status}`)} + + + + {trafficPercent !== null ? ( +
+ + + {t("accounts.traffic.format", { + used: formatBytes(Math.max(0, account.trafficTotal! - account.trafficLeft!)), + total: formatBytes(account.trafficTotal), + })} + +
+ ) : ( + {t("accounts.traffic.unknown")} + )} + + + {account.validUntil !== null + ? formatDate(account.validUntil, language) + : t("accounts.validUntil.never")} + + + {account.lastValidated !== null + ? formatDate(account.lastValidated, language) + : "—"} + + +
+ actions.toggleEnabled(account, checked)} + aria-label={t("accounts.status.active")} + /> + + + + + + + actions.edit(account)}> + {t("accounts.actions.edit")} + + actions.delete(account)} + className="text-destructive focus:text-destructive" + > + {t("accounts.actions.delete")} + + + +
+ + + ); +} + +function computeTrafficPercent( + trafficLeft: number | null, + trafficTotal: number | null, +): number | null { + if (trafficLeft === null || trafficTotal === null || trafficTotal <= 0) return null; + const used = Math.max(0, trafficTotal - trafficLeft); + return Math.min(100, (used / trafficTotal) * 100); +} diff --git a/src/views/AccountsView/AccountsView.tsx b/src/views/AccountsView/AccountsView.tsx new file mode 100644 index 00000000..3ec190b8 --- /dev/null +++ b/src/views/AccountsView/AccountsView.tsx @@ -0,0 +1,361 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { Plus } from "lucide-react"; +import { open as openDialog, save as saveDialog } from "@tauri-apps/plugin-dialog"; +import { tauriInvoke } from "@/api/client"; +import { useTauriMutation } from "@/api/hooks"; +import { accountQueries } from "@/api/queries"; +import { Button } from "@/components/ui/button"; +import { useAccountsQuery } from "@/hooks/useAccountsQuery"; +import { toast } from "@/lib/toast"; +import { useSettingsStore } from "@/stores/settingsStore"; +import type { + AccountPatch, + AccountType, + AccountView, + AddAccountInput, + ExportAccountsResult, + ImportAccountsResult, + ValidationOutcome, +} from "@/types/account"; +import { AccountList } from "./AccountList"; +import type { AccountRowActions } from "./AccountRow"; +import { AddAccountDialog } from "./AddAccountDialog"; +import { DeleteAccountDialog } from "./DeleteAccountDialog"; +import { EditAccountDialog } from "./EditAccountDialog"; +import { ExportAccountsDialog, ImportAccountsDialog } from "./ImportExportDialog"; + +const FILTER_ORDER: ReadonlyArray<"all" | AccountType> = ["all", "debrid", "premium", "free"]; +const INVALIDATE_KEYS = [accountQueries.all()] as const; + +export function AccountsView() { + const { t } = useTranslation(); + const [filter, setFilter] = useState<"all" | AccountType>("all"); + const [addOpen, setAddOpen] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [deleting, setDeleting] = useState(null); + const [editing, setEditing] = useState(null); + const [validatingIds, setValidatingIds] = useState>(() => new Set()); + + const confirmBeforeDelete = useSettingsStore((s) => s.config?.confirmDelete ?? true); + const queryClient = useQueryClient(); + + const invalidateAccountsList = useCallback(() => { + queryClient.invalidateQueries({ queryKey: accountQueries.all() }); + }, [queryClient]); + + const { data, isLoading, error } = useAccountsQuery(); + const accounts = useMemo(() => data ?? [], [data]); + + const counts = useMemo(() => { + const all = accounts.length; + let debrid = 0; + let premium = 0; + let free = 0; + for (const a of accounts) { + if (a.accountType === "debrid") debrid++; + else if (a.accountType === "premium") premium++; + else if (a.accountType === "free") free++; + } + return { all, debrid, premium, free }; + }, [accounts]); + + const filteredAccounts = useMemo(() => { + if (filter === "all") return accounts; + return accounts.filter((a) => a.accountType === filter); + }, [accounts, filter]); + + const addMut = useTauriMutation>( + "account_add", + { + invalidateKeys: INVALIDATE_KEYS, + errorMessage: () => t("accounts.toast.addError"), + }, + ); + + const updateMut = useTauriMutation< + void, + { id: string; patch: AccountPatch } & Record + >("account_update", { + invalidateKeys: INVALIDATE_KEYS, + errorMessage: () => t("accounts.toast.updateError"), + }); + + const deleteMut = useTauriMutation("account_delete", { + invalidateKeys: INVALIDATE_KEYS, + errorMessage: () => t("accounts.toast.deleteError"), + }); + + const handleAddSubmit = useCallback( + async (input: AddAccountInput) => { + await addMut.mutateAsync({ + serviceName: input.serviceName, + username: input.username, + password: input.password, + accountType: input.accountType, + }); + toast.success(t("accounts.toast.addSuccess")); + }, + [addMut, t], + ); + + const handleToggleEnabled = useCallback( + (account: AccountView, nextEnabled: boolean) => { + updateMut.mutate( + { id: account.id, patch: { enabled: nextEnabled } }, + { onSuccess: () => toast.success(t("accounts.toast.updateSuccess")) }, + ); + }, + [updateMut, t], + ); + + const handleEdit = useCallback((account: AccountView) => { + setEditing(account); + }, []); + + const handleEditSubmit = useCallback( + async (patch: AccountPatch) => { + if (!editing) return; + await updateMut.mutateAsync({ id: editing.id, patch }); + toast.success(t("accounts.toast.updateSuccess")); + }, + [editing, updateMut, t], + ); + + const requestDelete = useCallback( + (account: AccountView) => { + if (confirmBeforeDelete) { + setDeleting(account); + } else { + deleteMut.mutate( + { id: account.id }, + { onSuccess: () => toast.success(t("accounts.toast.deleteSuccess")) }, + ); + } + }, + [confirmBeforeDelete, deleteMut, t], + ); + + const confirmDelete = useCallback(() => { + if (!deleting) return; + deleteMut.mutate( + { id: deleting.id }, + { + onSuccess: () => { + toast.success(t("accounts.toast.deleteSuccess")); + setDeleting(null); + }, + }, + ); + }, [deleteMut, deleting, t]); + + const handleValidate = useCallback( + async (account: AccountView) => { + setValidatingIds((prev) => { + const next = new Set(prev); + next.add(account.id); + return next; + }); + try { + const outcome = await tauriInvoke("account_validate", { + id: account.id, + }); + if (outcome.valid) { + toast.success(t("accounts.toast.validateSuccess")); + } else { + toast.error( + t("accounts.toast.validateInvalid", { + reason: outcome.errorMessage ?? "—", + }), + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(`${t("accounts.toast.validateError")}: ${message}`); + } finally { + invalidateAccountsList(); + setValidatingIds((prev) => { + const next = new Set(prev); + next.delete(account.id); + return next; + }); + } + }, + [t, invalidateAccountsList], + ); + + const rowActions = useMemo( + () => ({ + validate: handleValidate, + edit: handleEdit, + delete: requestDelete, + toggleEnabled: handleToggleEnabled, + }), + [handleValidate, handleEdit, requestDelete, handleToggleEnabled], + ); + + const handleExport = useCallback( + async (passphrase: string) => { + const picked = await saveDialog({ + defaultPath: `vortex-accounts-${Date.now()}.vxbundle`, + filters: [{ name: "Vortex bundle", extensions: ["vxbundle"] }], + }).catch(() => null); + if (!picked) { + return; + } + try { + const result = await tauriInvoke("account_export", { + path: picked, + passphrase, + }); + toast.success( + t("accounts.toast.exportSuccess", { count: result.count }), + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(`${t("accounts.toast.exportError")}: ${message}`); + throw err; + } + }, + [t], + ); + + const pickImportFile = useCallback(async () => { + const picked = await openDialog({ + multiple: false, + directory: false, + filters: [{ name: "Vortex bundle", extensions: ["vxbundle"] }], + }).catch(() => null); + if (typeof picked === "string") return picked; + return null; + }, []); + + const handleImport = useCallback( + async (path: string, passphrase: string) => { + try { + const result = await tauriInvoke("account_import", { + path, + passphrase, + }); + toast.success( + t("accounts.toast.importSuccess", { count: result.imported }), + ); + if (result.skippedDuplicates > 0) { + toast.success( + t("accounts.toast.importSkipped", { count: result.skippedDuplicates }), + ); + } + invalidateAccountsList(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(`${t("accounts.toast.importError")}: ${message}`); + throw err; + } + }, + [t, invalidateAccountsList], + ); + + return ( +
+
+

{t("accounts.title")}

+
+ + + +
+
+ +
+ {FILTER_ORDER.map((value) => ( + + ))} +
+ + {error && ( +
+ {error.message} +
+ )} + + {isLoading ? ( +
+ {t("accounts.loading")} +
+ ) : ( + + )} + + + setDeleting(null)} + onConfirm={confirmDelete} + pending={deleteMut.isPending} + /> + setEditing(null)} + onSubmit={handleEditSubmit} + /> + + +
+ ); +} diff --git a/src/views/AccountsView/AddAccountDialog.tsx b/src/views/AccountsView/AddAccountDialog.tsx new file mode 100644 index 00000000..dd75fb0a --- /dev/null +++ b/src/views/AccountsView/AddAccountDialog.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { AccountType, AddAccountInput } from "@/types/account"; + +interface AddAccountDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultType?: AccountType; + onSubmit: (input: AddAccountInput) => Promise; +} + +const TYPE_OPTIONS: AccountType[] = ["debrid", "premium", "free"]; + +export function AddAccountDialog({ + open, + onOpenChange, + defaultType = "premium", + onSubmit, +}: AddAccountDialogProps) { + const { t } = useTranslation(); + const [serviceName, setServiceName] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [accountType, setAccountType] = useState(defaultType); + const [submitting, setSubmitting] = useState(false); + + const defaultTypeRef = useRef(defaultType); + defaultTypeRef.current = defaultType; + + useEffect(() => { + if (open) { + setServiceName(""); + setUsername(""); + setPassword(""); + setAccountType(defaultTypeRef.current); + setSubmitting(false); + } + }, [open]); + + const trimmedService = serviceName.trim(); + const trimmedUsername = username.trim(); + const canSubmit = + !submitting && + trimmedService.length > 0 && + trimmedUsername.length > 0 && + password.length > 0; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit) return; + setSubmitting(true); + try { + await onSubmit({ + serviceName: trimmedService, + username: trimmedUsername, + password, + accountType, + }); + onOpenChange(false); + } catch { + // Error toast surfaced by mutation; keep dialog open for retry. + } finally { + setSubmitting(false); + } + }; + + return ( + + + + {t("accounts.addDialog.title")} + {t("accounts.addDialog.description")} + +
+ + + +
+ {t("accounts.addDialog.type")} + + + {t("accounts.addDialog.typeHint")} + +
+ + + + +
+
+
+ ); +} diff --git a/src/views/AccountsView/DeleteAccountDialog.tsx b/src/views/AccountsView/DeleteAccountDialog.tsx new file mode 100644 index 00000000..67ce4a05 --- /dev/null +++ b/src/views/AccountsView/DeleteAccountDialog.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import type { AccountView } from "@/types/account"; + +interface DeleteAccountDialogProps { + account: AccountView | null; + onCancel: () => void; + onConfirm: () => void; + pending: boolean; +} + +export function DeleteAccountDialog({ + account, + onCancel, + onConfirm, + pending, +}: DeleteAccountDialogProps) { + const { t } = useTranslation(); + const open = account !== null; + + return ( + { + if (!next) onCancel(); + }} + > + + + {t("accounts.deleteDialog.title")} + + {account && + t("accounts.deleteDialog.description", { + username: account.username, + service: account.serviceName, + })} + + + + + + + + + ); +} diff --git a/src/views/AccountsView/EditAccountDialog.tsx b/src/views/AccountsView/EditAccountDialog.tsx new file mode 100644 index 00000000..97c11a61 --- /dev/null +++ b/src/views/AccountsView/EditAccountDialog.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { AccountPatch, AccountType, AccountView } from "@/types/account"; + +interface EditAccountDialogProps { + account: AccountView | null; + onCancel: () => void; + onSubmit: (patch: AccountPatch) => Promise; +} + +const TYPE_OPTIONS: AccountType[] = ["debrid", "premium", "free"]; + +export function EditAccountDialog({ account, onCancel, onSubmit }: EditAccountDialogProps) { + const { t } = useTranslation(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [accountType, setAccountType] = useState("premium"); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (account) { + setUsername(account.username); + setPassword(""); + setAccountType(account.accountType); + setSubmitting(false); + } + }, [account]); + + const trimmedUsername = username.trim(); + const canSubmit = !submitting && account !== null && trimmedUsername.length > 0; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit || account === null) return; + const patch: AccountPatch = {}; + if (trimmedUsername !== account.username) patch.username = trimmedUsername; + if (password.length > 0) patch.password = password; + if (accountType !== account.accountType) patch.accountType = accountType; + if (Object.keys(patch).length === 0) { + onCancel(); + return; + } + setSubmitting(true); + try { + await onSubmit(patch); + onCancel(); + } catch { + // Toast surfaced by mutation; keep dialog open. + } finally { + setSubmitting(false); + } + }; + + return ( + { + if (!next) onCancel(); + }} + > + + + {t("accounts.actions.edit")} + + {account?.serviceName ?? ""} + + +
+ + +
+ {t("accounts.addDialog.type")} + +
+ + + + +
+
+
+ ); +} diff --git a/src/views/AccountsView/ImportExportDialog.tsx b/src/views/AccountsView/ImportExportDialog.tsx new file mode 100644 index 00000000..79f656ea --- /dev/null +++ b/src/views/AccountsView/ImportExportDialog.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +interface ExportDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (passphrase: string) => Promise; +} + +export function ExportAccountsDialog({ open, onOpenChange, onSubmit }: ExportDialogProps) { + const { t } = useTranslation(); + const [passphrase, setPassphrase] = useState(""); + const [confirmPassphrase, setConfirmPassphrase] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (open) { + setPassphrase(""); + setConfirmPassphrase(""); + setSubmitting(false); + } + }, [open]); + + const mismatch = confirmPassphrase.length > 0 && passphrase !== confirmPassphrase; + const canSubmit = + !submitting && passphrase.length > 0 && passphrase === confirmPassphrase; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit) return; + setSubmitting(true); + try { + await onSubmit(passphrase); + onOpenChange(false); + } catch { + // Toast surfaced by caller; keep dialog open. + } finally { + setSubmitting(false); + } + }; + + return ( + + + + {t("accounts.exportDialog.title")} + {t("accounts.exportDialog.description")} + +
+ + + + + + +
+
+
+ ); +} + +interface ImportDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onPickFile: () => Promise; + onSubmit: (path: string, passphrase: string) => Promise; +} + +export function ImportAccountsDialog({ + open, + onOpenChange, + onPickFile, + onSubmit, +}: ImportDialogProps) { + const { t } = useTranslation(); + const [path, setPath] = useState(null); + const [passphrase, setPassphrase] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (open) { + setPath(null); + setPassphrase(""); + setSubmitting(false); + } + }, [open]); + + const handleBrowse = async () => { + const picked = await onPickFile(); + if (picked) setPath(picked); + }; + + const canSubmit = !submitting && path !== null && passphrase.length > 0; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit || path === null) return; + setSubmitting(true); + try { + await onSubmit(path, passphrase); + onOpenChange(false); + } catch { + // Toast surfaced by caller; keep dialog open. + } finally { + setSubmitting(false); + } + }; + + return ( + + + + {t("accounts.importDialog.title")} + {t("accounts.importDialog.description")} + +
+
+ {t("accounts.importDialog.filePath")} +
+ + +
+
+ + + + + +
+
+
+ ); +} diff --git a/src/views/AccountsView/__tests__/AccountsView.test.tsx b/src/views/AccountsView/__tests__/AccountsView.test.tsx new file mode 100644 index 00000000..304a8511 --- /dev/null +++ b/src/views/AccountsView/__tests__/AccountsView.test.tsx @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { invoke } from "@tauri-apps/api/core"; +import { save, open } from "@tauri-apps/plugin-dialog"; +import { toast } from "sonner"; +import type { AccountView } from "@/types/account"; +import { AccountsView } from "../AccountsView"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + save: vi.fn(), + open: vi.fn(), +})); + +const mockInvoke = vi.mocked(invoke); +const mockSave = vi.mocked(save); +const mockOpen = vi.mocked(open); +const mockToastSuccess = vi.mocked(toast.success); +const mockToastError = vi.mocked(toast.error); + +function sampleAccounts(): AccountView[] { + return [ + { + id: "rd-1", + serviceName: "real-debrid", + username: "alice", + accountType: "debrid", + enabled: true, + trafficLeft: 500_000, + trafficTotal: 1_000_000, + validUntil: Date.now() + 86_400_000, + lastValidated: Date.now() - 60_000, + createdAt: Date.now() - 86_400_000, + credentialRef: "keyring://real-debrid/alice", + }, + { + id: "ad-1", + serviceName: "alldebrid", + username: "bob", + accountType: "premium", + enabled: false, + trafficLeft: null, + trafficTotal: null, + validUntil: null, + lastValidated: null, + createdAt: Date.now() - 172_800_000, + credentialRef: "keyring://alldebrid/bob", + }, + ]; +} + +function renderView() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0 } }, + }); + render( + + + , + ); + return { client }; +} + +beforeEach(() => { + window.localStorage.setItem("i18nextLng", "en"); + mockInvoke.mockReset(); + mockSave.mockReset(); + mockOpen.mockReset(); + mockToastSuccess.mockClear(); + mockToastError.mockClear(); +}); + +describe("AccountsView", () => { + it("renders the accounts list returned by account_list", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "account_list") return sampleAccounts(); + return null; + }); + + renderView(); + + await waitFor(() => { + expect(screen.getByText("real-debrid")).toBeInTheDocument(); + expect(screen.getByText("alldebrid")).toBeInTheDocument(); + }); + expect(screen.queryByText(/coming soon/i)).not.toBeInTheDocument(); + expect(mockInvoke).toHaveBeenCalledWith( + "account_list", + expect.objectContaining({}), + ); + }); + + it("renders the empty state when no accounts exist", async () => { + mockInvoke.mockResolvedValue([]); + renderView(); + await waitFor(() => + expect(screen.getByTestId("accounts-empty")).toBeInTheDocument(), + ); + }); + + it("filters by category when a tab is clicked", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "account_list") return sampleAccounts(); + return null; + }); + + renderView(); + await waitFor(() => screen.getByText("real-debrid")); + + const user = userEvent.setup(); + await user.click(screen.getByTestId("accounts-filter-premium")); + + expect(screen.queryByText("real-debrid")).not.toBeInTheDocument(); + expect(screen.getByText("alldebrid")).toBeInTheDocument(); + }); + + it("calls account_add then refreshes the list when the add form is submitted", async () => { + let listCallCount = 0; + mockInvoke.mockImplementation(async (command: string) => { + if (command === "account_list") { + listCallCount += 1; + return listCallCount === 1 ? [] : sampleAccounts().slice(0, 1); + } + if (command === "account_add") return "rd-1"; + return null; + }); + + renderView(); + await waitFor(() => screen.getByTestId("accounts-empty")); + + const user = userEvent.setup(); + await user.click(screen.getByTestId("accounts-add-trigger")); + await user.type(screen.getByTestId("account-add-service"), "real-debrid"); + await user.type(screen.getByTestId("account-add-username"), "alice"); + await user.type(screen.getByTestId("account-add-password"), "s3cret"); + await user.click(screen.getByTestId("account-add-submit")); + + await waitFor(() => expect(mockToastSuccess).toHaveBeenCalled()); + expect(mockInvoke).toHaveBeenCalledWith( + "account_add", + expect.objectContaining({ + serviceName: "real-debrid", + username: "alice", + password: "s3cret", + accountType: "premium", + }), + ); + }); + + it("opens a confirm dialog and calls account_delete on confirmation", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "account_list") return sampleAccounts(); + if (command === "account_delete") return null; + return null; + }); + + renderView(); + await waitFor(() => screen.getByText("real-debrid")); + + const user = userEvent.setup(); + const row = screen.getByTestId("account-row-rd-1"); + const menuButton = within(row).getByTestId("account-row-menu-rd-1"); + await user.click(menuButton); + await user.click(await screen.findByRole("menuitem", { name: /delete/i })); + + const confirmButton = await screen.findByTestId("account-delete-confirm"); + await user.click(confirmButton); + + await waitFor(() => expect(mockToastSuccess).toHaveBeenCalled()); + expect(mockInvoke).toHaveBeenCalledWith("account_delete", { id: "rd-1" }); + }); + + it("disables the export trigger when there are no accounts", async () => { + mockInvoke.mockResolvedValue([]); + renderView(); + await waitFor(() => + expect(screen.getByTestId("accounts-empty")).toBeInTheDocument(), + ); + + expect(screen.getByTestId("accounts-export-trigger")).toBeDisabled(); + expect(screen.getByTestId("accounts-import-trigger")).not.toBeDisabled(); + }); + + it("invokes account_export with the chosen path and passphrase", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "account_list") return sampleAccounts(); + if (command === "account_export") { + return { path: "/tmp/bundle.vxbundle", count: 2 }; + } + return null; + }); + mockSave.mockResolvedValue("/tmp/bundle.vxbundle"); + + renderView(); + await waitFor(() => screen.getByText("real-debrid")); + + const user = userEvent.setup(); + await user.click(screen.getByTestId("accounts-export-trigger")); + await user.type( + screen.getByTestId("account-export-passphrase"), + "my-passphrase", + ); + await user.type( + screen.getByTestId("account-export-passphrase-confirm"), + "my-passphrase", + ); + await user.click(screen.getByTestId("account-export-submit")); + + await waitFor(() => expect(mockToastSuccess).toHaveBeenCalled()); + expect(mockInvoke).toHaveBeenCalledWith( + "account_export", + expect.objectContaining({ + path: "/tmp/bundle.vxbundle", + passphrase: "my-passphrase", + }), + ); + }); + + it("calls account_import after the file is picked and passphrase entered", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "account_list") return sampleAccounts(); + if (command === "account_import") { + return { path: "/tmp/in.vxbundle", imported: 2, skippedDuplicates: 0 }; + } + return null; + }); + mockOpen.mockResolvedValue("/tmp/in.vxbundle"); + + renderView(); + await waitFor(() => screen.getByText("real-debrid")); + + const user = userEvent.setup(); + await user.click(screen.getByTestId("accounts-import-trigger")); + await user.click(screen.getByText(/Browse/i)); + await waitFor(() => + expect( + (screen.getByTestId("account-import-path") as HTMLInputElement).value, + ).toBe("/tmp/in.vxbundle"), + ); + await user.type( + screen.getByTestId("account-import-passphrase"), + "my-passphrase", + ); + await user.click(screen.getByTestId("account-import-submit")); + + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith( + "account_import", + expect.objectContaining({ + path: "/tmp/in.vxbundle", + passphrase: "my-passphrase", + }), + ), + ); + }); +}); diff --git a/src/views/AccountsView/__tests__/statusUtils.test.ts b/src/views/AccountsView/__tests__/statusUtils.test.ts new file mode 100644 index 00000000..3554614a --- /dev/null +++ b/src/views/AccountsView/__tests__/statusUtils.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import type { AccountView } from "@/types/account"; +import { deriveAccountStatus } from "../statusUtils"; + +function base(overrides: Partial = {}): AccountView { + return { + id: "id", + serviceName: "real-debrid", + username: "alice", + accountType: "premium", + enabled: true, + trafficLeft: null, + trafficTotal: null, + validUntil: null, + lastValidated: null, + createdAt: 0, + credentialRef: "keyring://real-debrid/alice", + ...overrides, + }; +} + +describe("deriveAccountStatus", () => { + it("returns 'disabled' when the account is disabled even if otherwise valid", () => { + const account = base({ + enabled: false, + lastValidated: 1_000, + validUntil: 2_000_000_000_000, + }); + expect(deriveAccountStatus(account, 1)).toBe("disabled"); + }); + + it("returns 'expired' when valid_until is in the past", () => { + const account = base({ validUntil: 1, lastValidated: 0 }); + expect(deriveAccountStatus(account, 100)).toBe("expired"); + }); + + it("returns 'unverified' when lastValidated is null", () => { + const account = base({ lastValidated: null, validUntil: 100_000 }); + expect(deriveAccountStatus(account, 1)).toBe("unverified"); + }); + + it("returns 'active' when enabled, validated, not expired", () => { + const account = base({ lastValidated: 1, validUntil: 100_000 }); + expect(deriveAccountStatus(account, 1)).toBe("active"); + }); + + it("returns 'active' when validUntil is null but lastValidated set", () => { + const account = base({ lastValidated: 1, validUntil: null }); + expect(deriveAccountStatus(account, 1)).toBe("active"); + }); +}); diff --git a/src/views/AccountsView/index.ts b/src/views/AccountsView/index.ts new file mode 100644 index 00000000..aa0fccb8 --- /dev/null +++ b/src/views/AccountsView/index.ts @@ -0,0 +1 @@ +export { AccountsView } from "./AccountsView"; diff --git a/src/views/AccountsView/statusUtils.ts b/src/views/AccountsView/statusUtils.ts new file mode 100644 index 00000000..f3d4cfd3 --- /dev/null +++ b/src/views/AccountsView/statusUtils.ts @@ -0,0 +1,19 @@ +import type { AccountView } from "@/types/account"; + +export type AccountStatus = "active" | "expired" | "disabled" | "unverified"; + +/** + * Derives a UI status badge for an account row from its persisted state. + * Order matters: a disabled account is always shown as "disabled" even if + * its `valid_until` is still in the future, so users see the same label + * the toggle just produced. + */ +export function deriveAccountStatus( + account: AccountView, + nowMs: number = Date.now(), +): AccountStatus { + if (!account.enabled) return "disabled"; + if (account.validUntil !== null && account.validUntil < nowMs) return "expired"; + if (account.lastValidated === null) return "unverified"; + return "active"; +}