tokens for credentials
This commit is contained in:
parent
1621866cf1
commit
e68e9f26d1
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
name: Option<String>,
|
||||
) -> Result<EditApiCredentialResponse, String> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
cookie_header: Option<String>,
|
||||
) -> Result<EditWebCredentialResponse, String> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<P: AsRef<Path>>(path: P) -> Result<Self, ArtcraftXCredentialError> {
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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<CredentialToken>,
|
||||
|
||||
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::*;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<CredentialToken> = 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<Option<Credential>, 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<CredentialToken> = 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>) -> 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();
|
||||
|
|
|
|||
|
|
@ -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_",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod credentials;
|
||||
pub mod news_stories;
|
||||
pub mod tasks;
|
||||
pub mod tts_render_tasks;
|
||||
|
|
|
|||
|
|
@ -22,11 +22,11 @@ import { useAccountSelectorStore } from "./accountSelectorStore";
|
|||
*/
|
||||
export const AccountSelector = () => {
|
||||
const [credentials, setCredentials] = useState<CredentialPayload[]>([]);
|
||||
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: (
|
||||
<img
|
||||
src={getServiceLogoPath(credential.service)}
|
||||
|
|
@ -83,11 +86,11 @@ export const AccountSelector = () => {
|
|||
];
|
||||
|
||||
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 (
|
||||
|
|
|
|||
|
|
@ -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<AccountSelectorState>((set) => ({
|
||||
selectedAccountId: null,
|
||||
setSelectedAccountId: (id) => set({ selectedAccountId: id }),
|
||||
selectedAccountToken: null,
|
||||
setSelectedAccountToken: (token) => set({ selectedAccountToken: token }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const CredentialsManager = () => {
|
|||
<div className="flex flex-col gap-1.5">
|
||||
{credentials.map((credential) => (
|
||||
<CredentialRow
|
||||
key={credential.id}
|
||||
key={credential.token}
|
||||
credential={credential}
|
||||
onEdit={() => setEditing(credential)}
|
||||
onDelete={() => setDeleting(credential)}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<CredentialPayload> => {
|
||||
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<void> => {
|
||||
await invoke("delete_credentials_command", { fileName });
|
||||
export const deleteCredential = async (
|
||||
credentialToken: string
|
||||
): Promise<void> => {
|
||||
await invoke("delete_credentials_command", { credentialToken });
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue