diff --git a/crates/artcraftx/src/commands/credentials/add_api_credential_command.rs b/crates/artcraftx/src/commands/credentials/add_api_credential_command.rs index 0f1e13a1..cd127381 100644 --- a/crates/artcraftx/src/commands/credentials/add_api_credential_command.rs +++ b/crates/artcraftx/src/commands/credentials/add_api_credential_command.rs @@ -40,6 +40,7 @@ pub async fn add_api_credential_command( let source_path = creds_dir.next_available_credential_path(service); let credential = Credential { + token: creds_dir.generate_unique_credential_token(), service, name: normalize_name(name), secret: CredentialSecret::ApiKey(ApiKeyCredential::new(api_key)), diff --git a/crates/artcraftx/src/commands/credentials/add_web_credential_command.rs b/crates/artcraftx/src/commands/credentials/add_web_credential_command.rs index 7db70f93..08cdfb75 100644 --- a/crates/artcraftx/src/commands/credentials/add_web_credential_command.rs +++ b/crates/artcraftx/src/commands/credentials/add_web_credential_command.rs @@ -45,6 +45,7 @@ pub fn save_web_credential( let credential = match existing { Some(existing) => Credential { + token: existing.token, service: existing.service, name: existing.name, secret: CredentialSecret::Cookies(cookie), @@ -52,6 +53,7 @@ pub fn save_web_credential( source_path: existing.source_path, }, None => Credential { + token: creds_dir.generate_unique_credential_token(), service: save.service, name: None, secret: CredentialSecret::Cookies(cookie), diff --git a/crates/artcraftx/src/commands/credentials/artcraft_login_command.rs b/crates/artcraftx/src/commands/credentials/artcraft_login_command.rs index 8c7258dd..c1317f18 100644 --- a/crates/artcraftx/src/commands/credentials/artcraft_login_command.rs +++ b/crates/artcraftx/src/commands/credentials/artcraft_login_command.rs @@ -150,6 +150,7 @@ fn save_session_credential( let creds_dir = app_data_root.credentials_dir(); let credential = Credential { + token: creds_dir.generate_unique_credential_token(), service, name: None, secret: CredentialSecret::Cookies(cookie), diff --git a/crates/artcraftx/src/commands/credentials/credential_payload.rs b/crates/artcraftx/src/commands/credentials/credential_payload.rs index e506f81b..a421f471 100644 --- a/crates/artcraftx/src/commands/credentials/credential_payload.rs +++ b/crates/artcraftx/src/commands/credentials/credential_payload.rs @@ -2,12 +2,18 @@ use crate::credentials::credential::Credential; use crate::credentials::credential_service_type::{CredentialKind, CredentialServiceType}; use chrono::{DateTime, Utc}; use serde_derive::Serialize; +use tokens::tokens::sqlite::credentials::CredentialToken; /// Frontend-facing view of a stored credential. #[derive(Serialize)] pub struct CredentialPayload { - /// The credential's identifier: its file name within the credentials - /// directory (e.g. `fal_api_2.toml`). Pass this back to edit/delete. + /// The credential's stable identity (`credential_{entropy}`). Hidden from + /// users, but the effective primary identifier — pass this back to + /// edit/delete and use it to reference the credential anywhere. + pub token: CredentialToken, + /// The credential's file name within the credentials directory + /// (e.g. `fal_api_2.toml`). Informational; may change if the user renames + /// the file. pub id: String, pub service: CredentialServiceType, pub kind: CredentialKind, @@ -29,6 +35,7 @@ impl CredentialPayload { let api_key = credential.api_key(); let cookie = credential.cookies(); Self { + token: credential.token.clone(), id: credential.id(), service: credential.service, kind: credential.kind(), diff --git a/crates/artcraftx/src/commands/credentials/delete_credentials_command.rs b/crates/artcraftx/src/commands/credentials/delete_credentials_command.rs index 7edf4074..2a08f28d 100644 --- a/crates/artcraftx/src/commands/credentials/delete_credentials_command.rs +++ b/crates/artcraftx/src/commands/credentials/delete_credentials_command.rs @@ -2,21 +2,33 @@ use crate::state::data_dir::app_data_root::AppDataRoot; use log::{error, info}; use tauri::State; -/// Delete a credential file, identified by its file name within the -/// credentials directory (e.g. `fal_api_2.toml`). The frontend asks the -/// user for confirmation before calling this. +/// Delete a credential, identified by its stable token. The frontend asks +/// the user for confirmation before calling this. #[tauri::command] pub async fn delete_credentials_command( app_data_root: State<'_, AppDataRoot>, - file_name: String, + credential_token: String, ) -> Result<(), String> { - info!("delete_credentials_command called for: {}", file_name); + info!("delete_credentials_command called for: {}", credential_token); - app_data_root - .credentials_dir() - .delete_credential_file(&file_name) + let creds_dir = app_data_root.credentials_dir(); + + let credential = creds_dir + .find_credential_by_token(&credential_token) .map_err(|err| { - error!("Error deleting credential {}: {}", file_name, err); - format!("Error deleting credential {}: {}", file_name, err) + error!("Error looking up credential {}: {}", credential_token, err); + format!("Error looking up credential {}: {}", credential_token, err) + })? + .ok_or_else(|| { + let message = format!("No credential found for token {}", credential_token); + error!("{}", message); + message + })?; + + creds_dir + .delete_credential_file(&credential.id()) + .map_err(|err| { + error!("Error deleting credential {}: {}", credential_token, err); + format!("Error deleting credential {}: {}", credential_token, err) }) } diff --git a/crates/artcraftx/src/commands/credentials/edit_api_credential_command.rs b/crates/artcraftx/src/commands/credentials/edit_api_credential_command.rs index 12020d85..b811749b 100644 --- a/crates/artcraftx/src/commands/credentials/edit_api_credential_command.rs +++ b/crates/artcraftx/src/commands/credentials/edit_api_credential_command.rs @@ -11,31 +11,35 @@ pub struct EditApiCredentialResponse { pub credential: CredentialPayload, } -/// Edit an existing API-key credential file, identified by its file name -/// within the credentials directory (e.g. `fal_api_2.toml`). +/// Edit an existing API-key credential, identified by its stable token. /// /// `api_key` replaces the key when provided (non-empty). `name` replaces the /// label; an empty string clears it. Omitted fields are left unchanged. #[tauri::command] pub async fn edit_api_credential_command( app_data_root: State<'_, AppDataRoot>, - file_name: String, + credential_token: String, api_key: Option, name: Option, ) -> Result { - info!("edit_api_credential_command called for: {}", file_name); + info!("edit_api_credential_command called for: {}", credential_token); let creds_dir = app_data_root.credentials_dir(); let mut credential = creds_dir - .load_credential(&file_name) + .find_credential_by_token(&credential_token) .map_err(|err| { - error!("Error loading credential {}: {}", file_name, err); - format!("Error loading credential {}: {}", file_name, err) + error!("Error looking up credential {}: {}", credential_token, err); + format!("Error looking up credential {}: {}", credential_token, err) + })? + .ok_or_else(|| { + let message = format!("No credential found for token {}", credential_token); + error!("{}", message); + message })?; let CredentialSecret::ApiKey(existing_key) = &credential.secret else { - let message = format!("Credential {} is not an API key credential", file_name); + let message = format!("Credential {} is not an API key credential", credential_token); error!("{}", message); return Err(message); }; @@ -55,8 +59,8 @@ pub async fn edit_api_credential_command( creds_dir .save_credential(&credential) .map_err(|err| { - error!("Error saving credential {}: {}", file_name, err); - format!("Error saving credential {}: {}", file_name, err) + error!("Error saving credential {}: {}", credential_token, err); + format!("Error saving credential {}: {}", credential_token, err) })?; Ok(EditApiCredentialResponse { diff --git a/crates/artcraftx/src/commands/credentials/edit_web_credential_command.rs b/crates/artcraftx/src/commands/credentials/edit_web_credential_command.rs index 4e3924f4..1a5488a0 100644 --- a/crates/artcraftx/src/commands/credentials/edit_web_credential_command.rs +++ b/crates/artcraftx/src/commands/credentials/edit_web_credential_command.rs @@ -12,8 +12,8 @@ pub struct EditWebCredentialResponse { pub credential: CredentialPayload, } -/// Edit an existing web-login (cookie) credential file, identified by its file -/// name within the credentials directory (e.g. `artcraft_cookies_2.toml`). +/// Edit an existing web-login (cookie) credential, identified by its stable +/// token. /// /// `name` replaces the label; an empty string clears it. `cookie_header` /// replaces the cookies when provided (non-empty). Omitted fields are left @@ -21,23 +21,28 @@ pub struct EditWebCredentialResponse { #[tauri::command] pub async fn edit_web_credential_command( app_data_root: State<'_, AppDataRoot>, - file_name: String, + credential_token: String, name: Option, cookie_header: Option, ) -> Result { - info!("edit_web_credential_command called for: {}", file_name); + info!("edit_web_credential_command called for: {}", credential_token); let creds_dir = app_data_root.credentials_dir(); let mut credential = creds_dir - .load_credential(&file_name) + .find_credential_by_token(&credential_token) .map_err(|err| { - error!("Error loading credential {}: {}", file_name, err); - format!("Error loading credential {}: {}", file_name, err) + error!("Error looking up credential {}: {}", credential_token, err); + format!("Error looking up credential {}: {}", credential_token, err) + })? + .ok_or_else(|| { + let message = format!("No credential found for token {}", credential_token); + error!("{}", message); + message })?; let CredentialSecret::Cookies(existing_cookie) = &credential.secret else { - let message = format!("Credential {} is not a cookie credential", file_name); + let message = format!("Credential {} is not a cookie credential", credential_token); error!("{}", message); return Err(message); }; @@ -65,8 +70,8 @@ pub async fn edit_web_credential_command( creds_dir .save_credential(&credential) .map_err(|err| { - error!("Error saving credential {}: {}", file_name, err); - format!("Error saving credential {}: {}", file_name, err) + error!("Error saving credential {}: {}", credential_token, err); + format!("Error saving credential {}: {}", credential_token, err) })?; Ok(EditWebCredentialResponse { diff --git a/crates/artcraftx/src/credentials/credential.rs b/crates/artcraftx/src/credentials/credential.rs index 05bb5d93..524f933e 100644 --- a/crates/artcraftx/src/credentials/credential.rs +++ b/crates/artcraftx/src/credentials/credential.rs @@ -5,6 +5,7 @@ use crate::credentials::credential_service_type::{CredentialKind, CredentialServ use crate::credentials::credential_user_info::CredentialUserInfo; use crate::error::artcraftx_credential_error::ArtcraftXCredentialError; use std::path::{Path, PathBuf}; +use tokens::tokens::sqlite::credentials::CredentialToken; /// A validated, in-app credential. /// @@ -14,6 +15,10 @@ use std::path::{Path, PathBuf}; /// timestamps, etc.) #[derive(Clone, Debug)] pub struct Credential { + /// The credential's stable identity (`credential_{entropy}`), stored in the + /// TOML file. Hidden from users, but the effective primary identifier — + /// file names can be freely renamed. + pub token: CredentialToken, pub service: CredentialServiceType, /// Optional user-facing label to tell multiple credentials for the same /// service apart. Empty by default. @@ -41,10 +46,19 @@ impl CredentialSecret { } impl Credential { + /// Load a credential, minting (and persisting) a token if the file lacks + /// one so the identity stays stable across loads. pub fn load_from_file>(path: P) -> Result { let path = path.as_ref(); let file = CredentialFile::load(path)?; - file.into_credential(path.to_path_buf()) + let needs_token_backfill = file.token.is_none(); + let credential = file.into_credential(path.to_path_buf())?; + if needs_token_backfill { + if let Err(err) = credential.save() { + log::warn!("Could not persist backfilled credential token for {:?}: {}", path, err); + } + } + Ok(credential) } /// Rewrite this credential's TOML file in place. @@ -90,6 +104,7 @@ mod tests { let path = dir.path().join("fal_api_key.toml"); let credential = Credential { + token: CredentialToken::generate(), service: CredentialServiceType::FalApi, name: Some("work account".to_string()), secret: CredentialSecret::ApiKey(ApiKeyCredential::new("fal-secret-key-12345")), @@ -99,6 +114,7 @@ mod tests { credential.save().unwrap(); let loaded = Credential::load_from_file(&path).unwrap(); + assert_eq!(loaded.token, credential.token); assert_eq!(loaded.service, CredentialServiceType::FalApi); assert_eq!(loaded.name.as_deref(), Some("work account")); assert_eq!(loaded.api_key().unwrap().api_key, "fal-secret-key-12345"); diff --git a/crates/artcraftx/src/credentials/credential_file.rs b/crates/artcraftx/src/credentials/credential_file.rs index 03bf7930..d7ced328 100644 --- a/crates/artcraftx/src/credentials/credential_file.rs +++ b/crates/artcraftx/src/credentials/credential_file.rs @@ -6,6 +6,7 @@ use crate::credentials::credential_user_info::CredentialUserInfo; use crate::error::artcraftx_credential_error::ArtcraftXCredentialError; use serde_derive::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +use tokens::tokens::sqlite::credentials::CredentialToken; /// The on-disk (TOML) shape of a credential file. /// @@ -16,6 +17,7 @@ use std::path::{Path, PathBuf}; /// Example file (`~/Artcraft/artcraftx/credentials/higgsfield.toml`): /// /// ```toml +/// token = "credential_2f7h9k3mnp5qr8tv0wx4yz6ab" /// service = "higgsfield_cookies" /// /// [cookie] @@ -26,6 +28,11 @@ use std::path::{Path, PathBuf}; /// ``` #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct CredentialFile { + /// The credential's stable identity. Absent in older or hand-written + /// files; loading backfills a freshly generated token. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, + pub service: CredentialServiceType, /// Optional user-facing label. Empty by default. @@ -96,6 +103,7 @@ impl CredentialFile { } Ok(Credential { + token: self.token.unwrap_or_else(CredentialToken::generate), service: self.service, name: self.name, secret, @@ -110,6 +118,7 @@ impl CredentialFile { CredentialSecret::ApiKey(api_key) => (None, Some(api_key.clone())), }; Self { + token: Some(credential.token.clone()), service: credential.service, name: credential.name.clone(), cookie, @@ -225,6 +234,30 @@ mod tests { } } + mod token_tests { + use super::*; + + #[test] + fn token_in_file_is_preserved() { + let toml_text = r#" + token = "credential_test123" + service = "fal_api" + [api_key] + api_key = "x" + "#; + let file: CredentialFile = toml::from_str(toml_text).unwrap(); + let credential = file.into_credential(PathBuf::from("test.toml")).unwrap(); + assert_eq!(credential.token.as_str(), "credential_test123"); + } + + #[test] + fn missing_token_generates_one() { + let file: CredentialFile = toml::from_str(HAND_WRITTEN_API_KEY_FILE).unwrap(); + let credential = file.into_credential(PathBuf::from("test.toml")).unwrap(); + assert!(credential.token.as_str().starts_with("credential_")); + } + } + mod round_trip_tests { use super::*; diff --git a/crates/artcraftx/src/state/data_dir/subdirectory/app_credentials_dir.rs b/crates/artcraftx/src/state/data_dir/subdirectory/app_credentials_dir.rs index a8facb7c..51ce2ea4 100644 --- a/crates/artcraftx/src/state/data_dir/subdirectory/app_credentials_dir.rs +++ b/crates/artcraftx/src/state/data_dir/subdirectory/app_credentials_dir.rs @@ -3,7 +3,9 @@ use crate::credentials::credential_service_type::CredentialServiceType; use crate::error::artcraftx_credential_error::ArtcraftXCredentialError; use crate::error::artcraftx_error::ArtcraftXError; use crate::state::data_dir::subdirectory::trait_data_subdir::DataSubdir; +use std::collections::HashSet; use std::path::{Path, PathBuf}; +use tokens::tokens::sqlite::credentials::CredentialToken; /// The directory holding per-service credential TOML files /// (by default `~/Artcraft/artcraftx/credentials`). @@ -51,9 +53,24 @@ impl AppCredentialsDir { paths.sort(); let mut credentials = Vec::new(); + let mut seen_tokens: HashSet = HashSet::new(); for path in paths { match Credential::load_from_file(&path) { - Ok(credential) => credentials.push(credential), + Ok(mut credential) => { + // Tokens must be unique; a duplicate (eg. a copied file) gets a + // fresh token, persisted so it stays stable. + if seen_tokens.contains(&credential.token) { + credential.token = generate_token_not_in(&seen_tokens); + if let Err(err) = self.save_credential(&credential) { + log::warn!( + "Could not persist deduplicated credential token for {:?}: {}", + credential.source_path, err, + ); + } + } + seen_tokens.insert(credential.token.clone()); + credentials.push(credential); + }, Err(err) => { log::warn!("Skipping bad credential file: {}", err); }, @@ -119,6 +136,25 @@ impl AppCredentialsDir { Credential::load_from_file(path).map_err(ArtcraftXError::from) } + /// Find a credential by its token, the stable primary identifier. + pub fn find_credential_by_token( + &self, + token: &str, + ) -> Result, ArtcraftXError> { + Ok(self.load_credentials()? + .into_iter() + .find(|credential| credential.token.as_str() == token)) + } + + /// Generate a token no existing credential uses. Collisions are + /// astronomically unlikely; the check guards against them anyway. + pub fn generate_unique_credential_token(&self) -> CredentialToken { + let existing: HashSet = self.load_credentials() + .map(|credentials| credentials.into_iter().map(|c| c.token).collect()) + .unwrap_or_default(); + generate_token_not_in(&existing) + } + /// Delete a credential file by id (file name). pub fn delete_credential_file(&self, file_name: &str) -> Result<(), ArtcraftXError> { let path = self.resolve_credential_file(file_name)?; @@ -127,6 +163,15 @@ impl AppCredentialsDir { } } +fn generate_token_not_in(taken: &HashSet) -> CredentialToken { + loop { + let token = CredentialToken::generate(); + if !taken.contains(&token) { + return token; + } + } +} + fn has_toml_extension(path: &Path) -> bool { path.extension() .map(|ext| ext.eq_ignore_ascii_case("toml")) @@ -175,6 +220,7 @@ mod tests { let creds_dir = AppCredentialsDir::new_from(dir.path()); let credential = Credential { + token: CredentialToken::generate(), service: CredentialServiceType::FalApi, name: None, secret: CredentialSecret::ApiKey(ApiKeyCredential::new("fal-key-123")), @@ -206,6 +252,65 @@ mod tests { assert_eq!(third.file_name().unwrap(), "fal_api_3.toml"); } + #[test] + fn missing_token_is_backfilled_and_stable_across_loads() { + let dir = tempfile::tempdir().unwrap(); + write_file( + dir.path(), + "fal_api.toml", + "service = \"fal_api\"\n[api_key]\napi_key = \"fal-key-123\"\n", + ); + + let creds_dir = AppCredentialsDir::new_from(dir.path()); + let first_load = creds_dir.load_credentials().unwrap(); + assert_eq!(first_load.len(), 1); + let token = first_load[0].token.clone(); + assert!(token.as_str().starts_with("credential_")); + + // The generated token was persisted, so a reload sees the same one. + let second_load = creds_dir.load_credentials().unwrap(); + assert_eq!(second_load[0].token, token); + } + + #[test] + fn duplicate_tokens_are_deduplicated_on_load() { + let dir = tempfile::tempdir().unwrap(); + let duplicated = "token = \"credential_copied\"\nservice = \"fal_api\"\n[api_key]\napi_key = \"x\"\n"; + write_file(dir.path(), "fal_api.toml", duplicated); + write_file(dir.path(), "fal_api_2.toml", duplicated); + + let creds_dir = AppCredentialsDir::new_from(dir.path()); + let credentials = creds_dir.load_credentials().unwrap(); + + assert_eq!(credentials.len(), 2); + assert_ne!(credentials[0].token, credentials[1].token); + + // The reassigned token was persisted, so a reload is stable. + let reloaded = creds_dir.load_credentials().unwrap(); + let mut tokens: Vec<_> = credentials.iter().map(|c| c.token.clone()).collect(); + let mut reloaded_tokens: Vec<_> = reloaded.iter().map(|c| c.token.clone()).collect(); + tokens.sort(); + reloaded_tokens.sort(); + assert_eq!(tokens, reloaded_tokens); + } + + #[test] + fn find_credential_by_token_matches() { + let dir = tempfile::tempdir().unwrap(); + write_file( + dir.path(), + "fal_api.toml", + "token = \"credential_findme\"\nservice = \"fal_api\"\n[api_key]\napi_key = \"x\"\n", + ); + + let creds_dir = AppCredentialsDir::new_from(dir.path()); + let found = creds_dir.find_credential_by_token("credential_findme").unwrap(); + assert_eq!(found.unwrap().id(), "fal_api.toml"); + + let missing = creds_dir.find_credential_by_token("credential_nope").unwrap(); + assert!(missing.is_none()); + } + #[test] fn resolve_rejects_path_traversal() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/schema/public/tokens/src/prefixes.rs b/crates/schema/public/tokens/src/prefixes.rs index ca907a0e..9405f419 100644 --- a/crates/schema/public/tokens/src/prefixes.rs +++ b/crates/schema/public/tokens/src/prefixes.rs @@ -68,6 +68,7 @@ pub(crate) enum TokenPrefix { #[derive(Debug, Copy, Clone, Eq, PartialEq)] #[cfg_attr(test, derive(EnumIter, EnumCount))] pub(crate) enum TauriTokenPrefix { + Credential, Task, } @@ -162,6 +163,7 @@ impl PrefixGenerator for TokenPrefix { impl PrefixGenerator for TauriTokenPrefix { fn prefix(self) -> &'static str { match self { + TauriTokenPrefix::Credential => "credential_", TauriTokenPrefix::Task => "task_", } } diff --git a/crates/schema/public/tokens/src/tokens/sqlite/credentials.rs b/crates/schema/public/tokens/src/tokens/sqlite/credentials.rs new file mode 100644 index 00000000..d9258787 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/sqlite/credentials.rs @@ -0,0 +1,20 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::TauriTokenPrefix; + +/// The stable identifier for a stored credential in the Tauri desktop app. +/// +/// Unlike most tokens, this is not a database primary key: it lives inside +/// each credential TOML file in the app's credentials directory. It is hidden +/// from users but serves as the effective primary identifier for a credential +/// (file names can be freely renamed). +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct CredentialToken(pub String); + +impl_string_token!(CredentialToken); +impl_crockford_generator!(CredentialToken, 32usize, TauriTokenPrefix::Credential, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/sqlite/mod.rs b/crates/schema/public/tokens/src/tokens/sqlite/mod.rs index e639c06e..e57b5bd2 100644 --- a/crates/schema/public/tokens/src/tokens/sqlite/mod.rs +++ b/crates/schema/public/tokens/src/tokens/sqlite/mod.rs @@ -1,3 +1,4 @@ +pub mod credentials; pub mod news_stories; pub mod tasks; pub mod tts_render_tasks; diff --git a/frontend/apps/artcraft/app/src/components/account-selector/AccountSelector.tsx b/frontend/apps/artcraft/app/src/components/account-selector/AccountSelector.tsx index d4763212..c96957c5 100644 --- a/frontend/apps/artcraft/app/src/components/account-selector/AccountSelector.tsx +++ b/frontend/apps/artcraft/app/src/components/account-selector/AccountSelector.tsx @@ -22,11 +22,11 @@ import { useAccountSelectorStore } from "./accountSelectorStore"; */ export const AccountSelector = () => { const [credentials, setCredentials] = useState([]); - const selectedAccountId = useAccountSelectorStore( - (state) => state.selectedAccountId + const selectedAccountToken = useAccountSelectorStore( + (state) => state.selectedAccountToken ); - const setSelectedAccountId = useAccountSelectorStore( - (state) => state.setSelectedAccountId + const setSelectedAccountToken = useAccountSelectorStore( + (state) => state.setSelectedAccountToken ); const refresh = useCallback(async () => { @@ -50,21 +50,24 @@ export const AccountSelector = () => { // clear) when the selected credential is deleted. useEffect(() => { if (credentials.length === 0) { - if (selectedAccountId !== null) setSelectedAccountId(null); + if (selectedAccountToken !== null) setSelectedAccountToken(null); return; } - if (!credentials.some((credential) => credential.id === selectedAccountId)) { - setSelectedAccountId(credentials[0].id); + const selectionExists = credentials.some( + (credential) => credential.token === selectedAccountToken + ); + if (!selectionExists) { + setSelectedAccountToken(credentials[0].token); } - }, [credentials, selectedAccountId, setSelectedAccountId]); + }, [credentials, selectedAccountToken, setSelectedAccountToken]); const items: PopoverItem[] = credentials.length > 0 ? credentials.map((credential) => ({ label: accountLabel(credential), description: accountDescription(credential), - selected: credential.id === selectedAccountId, - action: credential.id, + selected: credential.token === selectedAccountToken, + action: credential.token, icon: ( { ]; const handleSelect = (item: PopoverItem) => { - if (item.action) setSelectedAccountId(item.action); + if (item.action) setSelectedAccountToken(item.action); }; const selectedCredential = credentials.find( - (credential) => credential.id === selectedAccountId + (credential) => credential.token === selectedAccountToken ); return ( diff --git a/frontend/apps/artcraft/app/src/components/account-selector/accountSelectorStore.ts b/frontend/apps/artcraft/app/src/components/account-selector/accountSelectorStore.ts index fe06e13e..ab82ebc5 100644 --- a/frontend/apps/artcraft/app/src/components/account-selector/accountSelectorStore.ts +++ b/frontend/apps/artcraft/app/src/components/account-selector/accountSelectorStore.ts @@ -2,16 +2,16 @@ import { create } from "zustand"; interface AccountSelectorState { /** - * Credential id (the credential's file name, e.g. `artcraft_2.toml`) of - * the account selected in the toolbar account pickers. Shared across all - * generation pages. `null` until accounts load (the selector defaults to - * the first account) or when no accounts exist. + * Stable credential token (`credential_{entropy}`) of the account selected + * in the toolbar account pickers. Shared across all generation pages. + * `null` until accounts load (the selector defaults to the first account) + * or when no accounts exist. */ - selectedAccountId: string | null; - setSelectedAccountId: (id: string | null) => void; + selectedAccountToken: string | null; + setSelectedAccountToken: (token: string | null) => void; } export const useAccountSelectorStore = create((set) => ({ - selectedAccountId: null, - setSelectedAccountId: (id) => set({ selectedAccountId: id }), + selectedAccountToken: null, + setSelectedAccountToken: (token) => set({ selectedAccountToken: token }), })); diff --git a/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/CredentialsManager.tsx b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/CredentialsManager.tsx index 50b71bd5..215f271c 100644 --- a/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/CredentialsManager.tsx +++ b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/CredentialsManager.tsx @@ -79,7 +79,7 @@ export const CredentialsManager = () => {
{credentials.map((credential) => ( setEditing(credential)} onDelete={() => setDeleting(credential)} diff --git a/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/DeleteCredentialModal.tsx b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/DeleteCredentialModal.tsx index 2a167815..56c57391 100644 --- a/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/DeleteCredentialModal.tsx +++ b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/DeleteCredentialModal.tsx @@ -39,7 +39,7 @@ export const DeleteCredentialModal = ({ try { // Deletes by file name (the credential id) so we're sure we're // removing exactly this one. - await deleteCredential(credential.id); + await deleteCredential(credential.token); onDeleted(); onClose(); } catch (e) { diff --git a/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/EditCredentialModal.tsx b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/EditCredentialModal.tsx index 1b8a277b..db8ff3cc 100644 --- a/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/EditCredentialModal.tsx +++ b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/EditCredentialModal.tsx @@ -47,7 +47,7 @@ export const EditCredentialModal = ({ setErrorMessage(""); try { await editApiCredential({ - fileName: credential.id, + credentialToken: credential.token, apiKey: apiKey.trim(), // Empty string clears the name on the Rust side. name: name.trim(), diff --git a/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/credential-helpers.ts b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/credential-helpers.ts index 99e1685c..0793f272 100644 --- a/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/credential-helpers.ts +++ b/frontend/libs/components/settings-modal/src/lib/panes/AccountSettings/credential-helpers.ts @@ -3,7 +3,14 @@ import { getServicesBasePath } from "@storyteller/model-list"; // Mirrors the Rust `CredentialPayload` (crates/artcraftx/src/commands/credentials). export interface CredentialPayload { - /** File name within the credentials directory, e.g. `fal_api_2.toml`. */ + /** + * Stable identity (`credential_{entropy}`) stored inside the credential + * file. Hidden from users, but the effective primary identifier — pass it + * back to edit/delete and use it to reference the credential anywhere. + */ + token: string; + /** File name within the credentials directory, e.g. `fal_api_2.toml`. + * Informational; changes if the user renames the file. */ id: string; service: string; kind: "cookies" | "api_key"; @@ -103,14 +110,14 @@ export const addApiCredential = async (args: { }; export const editApiCredential = async (args: { - fileName: string; + credentialToken: string; apiKey?: string; name?: string; }): Promise => { const response = await invoke<{ credential: CredentialPayload }>( "edit_api_credential_command", { - fileName: args.fileName, + credentialToken: args.credentialToken, apiKey: args.apiKey ?? null, name: args.name ?? null, } @@ -118,8 +125,10 @@ export const editApiCredential = async (args: { return response.credential; }; -export const deleteCredential = async (fileName: string): Promise => { - await invoke("delete_credentials_command", { fileName }); +export const deleteCredential = async ( + credentialToken: string +): Promise => { + await invoke("delete_credentials_command", { credentialToken }); }; /**