Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
278 changes: 269 additions & 9 deletions src-tauri/src/adapters/driving/tauri_ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,37 @@ 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;
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::{
Expand Down Expand Up @@ -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<AccountType, String> {
raw.parse::<AccountType>().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<String>,
pub password: Option<String>,
pub account_type: Option<String>,
pub enabled: Option<bool>,
}

impl AccountPatchDto {
fn into_domain(self) -> Result<AccountPatch, String> {
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<u64>,
pub traffic_left: Option<u64>,
pub traffic_total: Option<u64>,
pub valid_until: Option<u64>,
pub error_message: Option<String>,
}

impl From<ValidationOutcomeDto> 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<ExportAccountsOutcome> 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<ImportAccountsOutcome> 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<String, String> {
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<ValidationOutcomeView, String> {
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<ExportAccountsView, String> {
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<ImportAccountsView, String> {
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<String>,
account_type: Option<String>,
enabled: Option<bool>,
) -> Result<Vec<AccountViewDto>, 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,
})
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<AccountViewDto, String> {
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<AccountTrafficDto, String> {
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::{
Expand Down
Loading
Loading