Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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. Eight 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
- **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
275 changes: 266 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,257 @@ 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 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