From 396732e28db79922fd9e2cc6fd586dfac75f7c1f Mon Sep 17 00:00:00 2001 From: John Lewis Date: Sun, 7 Sep 2025 17:32:57 -0700 Subject: [PATCH 01/11] feat(models+prime-domain): relax store name index requirements --- crates/models/src/store.rs | 21 +++++--- crates/prime-domain/src/lib.rs | 1 + crates/prime-domain/src/search_by_user.rs | 60 +++++++++++++++++++++++ crates/prime-domain/src/upload.rs | 52 ++++++++++++++------ 4 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 crates/prime-domain/src/search_by_user.rs diff --git a/crates/models/src/store.rs b/crates/models/src/store.rs index 186b9aaf..b3f09e44 100644 --- a/crates/models/src/store.rs +++ b/crates/models/src/store.rs @@ -26,9 +26,14 @@ pub struct Store { impl Store { /// Generates the value of the unique [`Store`] index - /// `name`. - pub fn unique_index_name(&self) -> Vec { - vec![self.name.clone().into_inner().into()] + /// `name_by_org`. + pub fn unique_index_name_by_org(&self) -> EitherSlug { + LaxSlug::new(format!( + "{org_id}-{name}", + org_id = self.org, + name = self.name + )) + .into() } } @@ -62,14 +67,14 @@ impl From for PvStore { /// The unique index selector for [`Store`]. #[derive(Debug, Clone, Copy)] pub enum StoreUniqueIndexSelector { - /// The `name` index. - Name, + /// The `name-by-org` index. + NameByOrg, } impl fmt::Display for StoreUniqueIndexSelector { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - StoreUniqueIndexSelector::Name => write!(f, "name"), + StoreUniqueIndexSelector::NameByOrg => write!(f, "name-by-org"), } } } @@ -107,7 +112,9 @@ impl Model for Store { const UNIQUE_INDICES: &'static [( Self::UniqueIndexSelector, SlugFieldGetter, - )] = &[(StoreUniqueIndexSelector::Name, Store::unique_index_name)]; + )] = &[(StoreUniqueIndexSelector::NameByOrg, |s| { + vec![Store::unique_index_name_by_org(s)] + })]; fn id(&self) -> dvf::RecordId { self.id } } diff --git a/crates/prime-domain/src/lib.rs b/crates/prime-domain/src/lib.rs index 37b8e287..2a65d618 100644 --- a/crates/prime-domain/src/lib.rs +++ b/crates/prime-domain/src/lib.rs @@ -8,6 +8,7 @@ mod fetch_by_name; mod fetch_by_org; mod migrate; pub mod narinfo; +mod search_by_user; pub mod upload; pub use belt; diff --git a/crates/prime-domain/src/search_by_user.rs b/crates/prime-domain/src/search_by_user.rs new file mode 100644 index 00000000..a3dee18e --- /dev/null +++ b/crates/prime-domain/src/search_by_user.rs @@ -0,0 +1,60 @@ +use db::{FetchModelByIndexError, FetchModelError, kv::LaxSlug}; +use models::{ + Store, StoreUniqueIndexSelector, User, + dvf::{EntityName, RecordId}, +}; + +use crate::PrimeDomainService; + +#[derive(Debug, thiserror::Error)] +pub enum SearchByUserError { + /// Indicates that the user does not exist. + #[error("Failed to find user: {0}")] + MissingUser(RecordId), + /// Indicates that a database error occurred. + #[error("Failed to fetch users by index")] + FetchError(#[from] FetchModelError), + /// Indicates that a database error occurred. + #[error("Failed to fetch users by index")] + FetchByIndexError(#[from] FetchModelByIndexError), +} + +impl PrimeDomainService { + /// Find all stores with the given name across all a user's orgs. + pub async fn search_stores_by_name_and_user( + &self, + user_id: RecordId, + store_name: EntityName, + ) -> Result, SearchByUserError> { + // fetch the user + let user = self + .fetch_user_by_id(user_id) + .await? + .ok_or(SearchByUserError::MissingUser(user_id))?; + + // the user's orgs + let user_orgs = user.iter_orgs(); + + // find the store with the given name in each org + let mut stores = Vec::new(); + for org in user_orgs { + // calculate the `NameByOrg` index + let index_value = LaxSlug::new(format!("{org}-{store_name}")); + // find the associated store + let store = self + .store_repo + .fetch_model_by_unique_index( + StoreUniqueIndexSelector::NameByOrg, + index_value.into(), + ) + .await?; + + // if a store with that name exists, push it + if let Some(store) = store { + stores.push(store); + } + } + + Ok(stores) + } +} diff --git a/crates/prime-domain/src/upload.rs b/crates/prime-domain/src/upload.rs index b19e783f..42738c45 100644 --- a/crates/prime-domain/src/upload.rs +++ b/crates/prime-domain/src/upload.rs @@ -6,14 +6,13 @@ use belt::Belt; use miette::{Context, IntoDiagnostic, miette}; use models::{ Cache, CacheUniqueIndexSelector, Digest, Entry, EntryUniqueIndexSelector, - NarAuthenticityData, NarDeriverData, NarStorageData, StorePath, - StoreUniqueIndexSelector, User, + NarAuthenticityData, NarDeriverData, NarStorageData, Org, StorePath, User, dvf::{CompressionStatus, EitherSlug, EntityName, RecordId}, model::Model, }; use serde::{Deserialize, Serialize}; -use crate::PrimeDomainService; +use crate::{PrimeDomainService, search_by_user::SearchByUserError}; /// The request struct for the [`upload`](PrimeDomainService::upload) fn. #[derive(Debug)] @@ -51,6 +50,12 @@ pub enum UploadError { /// The target store was not found. #[error("The target store was not found: \"{0}\"")] TargetStoreNotFound(EntityName), + /// Multiple stores were found with the given name in different organizations. + #[error( + "The target store name \"{1}\" is ambiguous: multiple results found in \ + orgs {0:?}" + )] + TargetStoreAmbiguous(Vec>, EntityName), /// An entry with that path already exists in the target store. #[error("An entry with that path already exists in the target store: {0}")] DuplicateEntryInStore(RecordId), @@ -98,18 +103,37 @@ impl PrimeDomainService { .ok_or(miette!("authenticated user not found")) .map_err(UploadError::InternalError)?; - // find the given store - let target_store = self - .store_repo - .fetch_model_by_unique_index( - StoreUniqueIndexSelector::Name, - EitherSlug::Strict(req.target_store.clone().into_inner()), - ) + // find the stores the user could be referring to + let possible_stores = self + .search_stores_by_name_and_user(user.id, req.target_store.clone()) .await - .into_diagnostic() - .context("failed to search for target store") - .map_err(UploadError::InternalError)? - .ok_or(UploadError::TargetStoreNotFound(req.target_store))?; + .map_err(|e| match e { + SearchByUserError::MissingUser(u) => { + unreachable!("user {u} was already fetched") + } + SearchByUserError::FetchError(e) => UploadError::InternalError( + Err::<(), _>(e) + .into_diagnostic() + .context("failed to search for stores by user") + .unwrap_err(), + ), + SearchByUserError::FetchByIndexError(e) => UploadError::InternalError( + Err::<(), _>(e) + .into_diagnostic() + .context("failed to search for stores by user") + .unwrap_err(), + ), + })?; + + // make sure there's only one + let target_store = match possible_stores.len() { + 0 => Err(UploadError::TargetStoreNotFound(req.target_store)), + 1 => Ok(possible_stores.first().unwrap().clone()), + _ => Err(UploadError::TargetStoreAmbiguous( + possible_stores.iter().map(|s| s.org).collect(), + req.target_store, + )), + }?; // org is assigned by the store let org = target_store.org; From 4effa7254bf7857a71190681ae60107c287254dc Mon Sep 17 00:00:00 2001 From: John Lewis Date: Sun, 7 Sep 2025 17:32:57 -0700 Subject: [PATCH 02/11] fix(app): disable table row color transition --- crates/site-app/style/src/components/table.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/site-app/style/src/components/table.css b/crates/site-app/style/src/components/table.css index c72d0894..9e4ed381 100644 --- a/crates/site-app/style/src/components/table.css +++ b/crates/site-app/style/src/components/table.css @@ -22,7 +22,7 @@ .table tbody > tr { /* behavior */ - @apply transition-colors; + /* @apply transition-colors; */ /* color */ @apply bg-base-1 even:bg-base-3 hover:bg-base-4; From 8902cbee35ba079e22007f2d5fe21511eaebc42f Mon Sep 17 00:00:00 2001 From: John Lewis Date: Sun, 7 Sep 2025 17:32:57 -0700 Subject: [PATCH 03/11] feat(app+prime-domain): add check_if_cache_name_is_available --- crates/prime-domain/src/fetch_by_name.rs | 22 +++++++++++++-- crates/site-app/src/resources/store.rs | 36 ++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/prime-domain/src/fetch_by_name.rs b/crates/prime-domain/src/fetch_by_name.rs index 4ea10c62..57dc6d13 100644 --- a/crates/prime-domain/src/fetch_by_name.rs +++ b/crates/prime-domain/src/fetch_by_name.rs @@ -1,5 +1,8 @@ -use db::FetchModelByIndexError; -use models::{Cache, CacheUniqueIndexSelector, dvf::EntityName}; +use db::{FetchModelByIndexError, kv::LaxSlug}; +use models::{ + Cache, CacheUniqueIndexSelector, Org, Store, StoreUniqueIndexSelector, + dvf::{EntityName, RecordId}, +}; use crate::PrimeDomainService; @@ -17,4 +20,19 @@ impl PrimeDomainService { ) .await } + + /// Fetches a [`Store`] by its org and name. + pub async fn fetch_store_by_org_and_name( + &self, + org: RecordId, + store_name: EntityName, + ) -> Result, FetchModelByIndexError> { + self + .store_repo + .fetch_model_by_unique_index( + StoreUniqueIndexSelector::NameByOrg, + LaxSlug::new(format!("{org}-{store_name}")).into(), + ) + .await + } } diff --git a/crates/site-app/src/resources/store.rs b/crates/site-app/src/resources/store.rs index 2232296d..82dbb48c 100644 --- a/crates/site-app/src/resources/store.rs +++ b/crates/site-app/src/resources/store.rs @@ -89,6 +89,42 @@ pub async fn fetch_stores_in_org( Ok(models) } +pub fn store_name_is_available_query_scope( +) -> QueryScope<(RecordId, String), Result> { + QueryScope::new(check_if_store_name_is_available) + .with_invalidation_link(move |_| [Store::TABLE_NAME]) +} + +#[server(prefix = "/api/sfn")] +pub async fn check_if_store_name_is_available( + org_and_name: (RecordId, String), +) -> Result { + use models::dvf::{EntityName, StrictSlug}; + use prime_domain::PrimeDomainService; + + let (org, name) = org_and_name; + + authorize_for_org(org)?; + + let sanitized_name = EntityName::new(StrictSlug::new(name.clone())); + if name != sanitized_name.clone().to_string() { + return Err(ServerFnError::new("name is unsanitized")); + } + + let prime_domain_service: PrimeDomainService = expect_context(); + + let occupied = prime_domain_service + .fetch_store_by_org_and_name(org, sanitized_name) + .await + .map_err(|e| { + tracing::error!("failed to fetch store by name: {e}"); + ServerFnError::new("internal error") + })? + .is_some(); + + Ok(!occupied) +} + pub fn entry_count_in_store_query_scope( ) -> QueryScope, Result> { QueryScope::new(count_entries_in_store).with_invalidation_link(move |s| { From a19cf606af1b9076cdcf98fc5c8ab2f0c33f45f5 Mon Sep 17 00:00:00 2001 From: John Lewis Date: Sun, 7 Sep 2025 17:32:57 -0700 Subject: [PATCH 04/11] feat(app): add subtle scale effect to buttons --- crates/site-app/style/src/components/btn-link.css | 1 + crates/site-app/style/src/components/btn.css | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/site-app/style/src/components/btn-link.css b/crates/site-app/style/src/components/btn-link.css index dbcedd99..b5e99ec4 100644 --- a/crates/site-app/style/src/components/btn-link.css +++ b/crates/site-app/style/src/components/btn-link.css @@ -4,6 +4,7 @@ .btn-link { /* behavior */ @apply cursor-pointer transition-colors select-none; + @apply active:scale-[0.98] ease-out; /* appearance */ @apply rounded-md; diff --git a/crates/site-app/style/src/components/btn.css b/crates/site-app/style/src/components/btn.css index ccfc6eae..9140d723 100644 --- a/crates/site-app/style/src/components/btn.css +++ b/crates/site-app/style/src/components/btn.css @@ -4,6 +4,7 @@ .btn { /* behavior */ @apply cursor-pointer transition select-none; + @apply active:scale-[0.98] ease-out; /* appearance */ @apply rounded-md; From 7b2c60bcb18a21f8f3f17bd7a94bcc20e74d887e Mon Sep 17 00:00:00 2001 From: John Lewis Date: Sun, 7 Sep 2025 17:32:57 -0700 Subject: [PATCH 05/11] fix(app): adjust dashboard breakpoints --- crates/site-app/src/pages/dashboard.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/site-app/src/pages/dashboard.rs b/crates/site-app/src/pages/dashboard.rs index c50a0720..ffbc192f 100644 --- a/crates/site-app/src/pages/dashboard.rs +++ b/crates/site-app/src/pages/dashboard.rs @@ -13,10 +13,10 @@ use self::{ #[component] pub fn DashboardPage() -> impl IntoView { view! { -
-

"Dashboard"

- -
+
+

"Dashboard"

+ +
From e5bf107aae0c56fdbbd90070f689cc233a757e5c Mon Sep 17 00:00:00 2001 From: John Lewis Date: Sun, 7 Sep 2025 17:32:57 -0700 Subject: [PATCH 06/11] fix(app): slight visual tweaks to org selector --- crates/site-app/src/components/navbar/org_selector.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/site-app/src/components/navbar/org_selector.rs b/crates/site-app/src/components/navbar/org_selector.rs index f887867c..fd0251ee 100644 --- a/crates/site-app/src/components/navbar/org_selector.rs +++ b/crates/site-app/src/components/navbar/org_selector.rs @@ -10,9 +10,9 @@ use crate::{ #[island] pub(super) fn OrgSelectorPopover(user: AuthUser) -> impl IntoView { - const CONTAINER_CLASS: &str = "hover:bg-base-3 active:bg-base-4 \ + const CONTAINER_CLASS: &str = "transition hover:bg-base-3 active:bg-base-4 \ cursor-pointer px-2 py-1 rounded flex \ - flex-col gap leading-none items-end gap-0.5"; + flex-col gap leading-none items-end gap-0"; let active_org_hook = OrgHook::new_active(); let active_org_descriptor = active_org_hook.descriptor(); @@ -44,7 +44,7 @@ pub(super) fn OrgSelectorPopover(user: AuthUser) -> impl IntoView { view! {
- { user.name.to_string() } + { user.name.to_string() }
From 6cb87f4b10d77d85949fbff9a33f58a21be05c0b Mon Sep 17 00:00:00 2001 From: John Lewis Date: Sun, 7 Sep 2025 17:32:57 -0700 Subject: [PATCH 07/11] fix(app): various fixes to create_cache --- crates/site-app/src/pages/create_cache.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/site-app/src/pages/create_cache.rs b/crates/site-app/src/pages/create_cache.rs index 2892a5f9..becdb01d 100644 --- a/crates/site-app/src/pages/create_cache.rs +++ b/crates/site-app/src/pages/create_cache.rs @@ -36,7 +36,6 @@ pub fn CreateCachePage() -> impl IntoView { }); let (read_name, write_name) = touched_input_bindings(name); let visibility = RwSignal::new(Visibility::Private); - let submit_touched = RwSignal::new(false); let is_available_query_scope = crate::resources::cache::cache_name_is_available_query_scope(); @@ -46,9 +45,9 @@ pub fn CreateCachePage() -> impl IntoView { }); let action = ServerAction::::new(); - // loading if the result is unpopulated or successful - let loading = move || { - submit_touched() && matches!(action.value().get(), None | Some(Ok(_))) + let loading = { + let (pending, value) = (action.pending(), action.value()); + move || pending() || matches!(value.get(), Some(Ok(_))) }; // error text for name field @@ -76,8 +75,6 @@ pub fn CreateCachePage() -> impl IntoView { // submit callback let org = org_hook.key(); let submit_action = move |_| { - submit_touched.set(true); - // the name has been checked and is available if sanitized_name().is_some() && matches!(is_available_resource.get(), Some(Some(Ok(true)))) @@ -160,7 +157,7 @@ pub async fn create_cache( .create_cache(org, sanitized_name, visibility) .await .map_err(|e| { - tracing::error!("failed to fetch org: {e}"); + tracing::error!("failed to create cache: {e}"); ServerFnError::new("internal error") }) } From ae7d3b76343ce0e73f9f6782b44b8a5365aebb77 Mon Sep 17 00:00:00 2001 From: John Lewis Date: Sun, 7 Sep 2025 17:32:57 -0700 Subject: [PATCH 08/11] fix(app): extend label to whole input --- crates/site-app/src/components/input_field.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/site-app/src/components/input_field.rs b/crates/site-app/src/components/input_field.rs index 08b2e1df..c4228bc9 100644 --- a/crates/site-app/src/components/input_field.rs +++ b/crates/site-app/src/components/input_field.rs @@ -57,8 +57,8 @@ pub fn InputField( const WARN_HINT_CLASS: &str = "text-warn-11 text-sm"; view! { -
- +
+ } } From 3dee4386b25ca536b5627d4cb4db154dfe62e98d Mon Sep 17 00:00:00 2001 From: John Lewis Date: Sun, 7 Sep 2025 17:32:57 -0700 Subject: [PATCH 09/11] feat(cli): add more response debugging --- crates/cli/src/upload.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/upload.rs b/crates/cli/src/upload.rs index cc320a1c..787c0ab0 100644 --- a/crates/cli/src/upload.rs +++ b/crates/cli/src/upload.rs @@ -147,13 +147,19 @@ impl Action for UploadCommand { .into_diagnostic() .context("failed to send upload request")?; - let json_resp = resp - .json::() + let text_resp = resp + .text() .await + .into_diagnostic() + .context("failed to read response body")?; + + tracing::debug!(body = text_resp, "got upload response"); + + let json_resp: serde_json::Value = serde_json::from_str(&text_resp) .into_diagnostic() .context("failed to deserialize response body as JSON")?; - tracing::debug!(body = ?json_resp, "got upload response"); + tracing::debug!(body = ?json_resp, "parsed upload response"); Ok(()) } From acfe2e741475df77036181ba274a3d433138b03f Mon Sep 17 00:00:00 2001 From: John Lewis Date: Sun, 7 Sep 2025 17:32:57 -0700 Subject: [PATCH 10/11] feat(app): add `CreateStore` page --- crates/prime-domain/src/create.rs | 23 ++- .../site-app/src/components/create_button.rs | 14 ++ crates/site-app/src/lib.rs | 1 + crates/site-app/src/pages.rs | 5 +- crates/site-app/src/pages/create_store.rs | 186 ++++++++++++++++++ .../pages/create_store/credentials_input.rs | 109 ++++++++++ crates/site-app/src/pages/dashboard/store.rs | 3 +- 7 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 crates/site-app/src/pages/create_store.rs create mode 100644 crates/site-app/src/pages/create_store/credentials_input.rs diff --git a/crates/prime-domain/src/create.rs b/crates/prime-domain/src/create.rs index d7920c16..720a70ae 100644 --- a/crates/prime-domain/src/create.rs +++ b/crates/prime-domain/src/create.rs @@ -1,6 +1,6 @@ use db::CreateModelError; use models::{ - Cache, Org, + Cache, Org, StorageCredentials, Store, StoreConfiguration, dvf::{EntityName, RecordId, Visibility}, }; @@ -25,4 +25,25 @@ impl PrimeDomainService { .await .map(|c| c.id) } + + /// Creates a [`Store`]. + pub async fn create_store( + &self, + org: RecordId, + name: EntityName, + credentials: StorageCredentials, + config: StoreConfiguration, + ) -> Result, CreateModelError> { + self + .store_repo + .create_model(Store { + id: RecordId::new(), + org, + credentials, + config, + name, + }) + .await + .map(|s| s.id) + } } diff --git a/crates/site-app/src/components/create_button.rs b/crates/site-app/src/components/create_button.rs index bac1ba37..4ffbd433 100644 --- a/crates/site-app/src/components/create_button.rs +++ b/crates/site-app/src/components/create_button.rs @@ -15,3 +15,17 @@ pub fn CreateCacheButton(text: &'static str) -> impl IntoView { } } + +#[component] +pub fn CreateStoreButton(text: &'static str) -> impl IntoView { + let org_hook = OrgHook::new_requested(); + let base_url = org_hook.base_url(); + let href = + Signal::derive(move || format!("{base}/create_store", base = base_url())); + + view! { + + { text } + + } +} diff --git a/crates/site-app/src/lib.rs b/crates/site-app/src/lib.rs index 5657b57a..7fea308e 100644 --- a/crates/site-app/src/lib.rs +++ b/crates/site-app/src/lib.rs @@ -75,6 +75,7 @@ pub fn App() -> impl IntoView { + diff --git a/crates/site-app/src/pages.rs b/crates/site-app/src/pages.rs index 7984617f..3ee4dadb 100644 --- a/crates/site-app/src/pages.rs +++ b/crates/site-app/src/pages.rs @@ -1,4 +1,5 @@ mod create_cache; +mod create_store; mod dashboard; mod entry; mod homepage; @@ -9,6 +10,6 @@ mod signup; mod unauthorized; pub use self::{ - create_cache::*, dashboard::*, entry::*, homepage::*, login::*, logout::*, - protected::*, signup::*, unauthorized::*, + create_cache::*, create_store::*, dashboard::*, entry::*, homepage::*, + login::*, logout::*, protected::*, signup::*, unauthorized::*, }; diff --git a/crates/site-app/src/pages/create_store.rs b/crates/site-app/src/pages/create_store.rs new file mode 100644 index 00000000..eddf9ca5 --- /dev/null +++ b/crates/site-app/src/pages/create_store.rs @@ -0,0 +1,186 @@ +mod credentials_input; + +use leptos::{prelude::*, server_fn::codec::Json}; +use leptos_fetch::QueryClient; +use models::{ + dvf::{EntityName, RecordId, StrictSlug}, + Org, R2StorageCredentials, StorageCredentials, Store, StoreConfiguration, +}; + +use self::credentials_input::CredentialsInput; +use crate::{ + components::{InputField, InputIcon, LoadingCircle}, + hooks::OrgHook, + navigation::navigate_to, + reactive_utils::touched_input_bindings, +}; + +const STORE_DESCRIPTION: &str = + "A store represents a storage location for entries, for example an S3 \ + bucket. The store holds credentials for the storage location, and \ + configuration specifying how the entries it contains will be encoded. + + Stores are immutable. To change a store's credentials or encoding \ + configuration, you will need to create a new store and migrate the old \ + store's entries to it. This incurs compute costs."; + +#[island] +pub fn CreateStorePage() -> impl IntoView { + let org_hook = OrgHook::new_requested(); + let org_key = org_hook.key(); + + let name = RwSignal::new(String::new()); + let sanitized_name = Memo::new(move |_| { + Some(EntityName::new(StrictSlug::new(name()))) + .filter(|n| !n.to_string().is_empty()) + }); + let (read_name, write_name) = touched_input_bindings(name); + let credentials = RwSignal::>::new(None); + let submit_touched = RwSignal::new(false); + + let is_available_query_scope = + crate::resources::store::store_name_is_available_query_scope(); + let is_available_resource = expect_context::() + .local_resource(is_available_query_scope, move || { + sanitized_name().map(|n| (org_key(), n.to_string())) + }); + + let action = ServerAction::::new(); + let loading = { + let (pending, value) = (action.pending(), action.value()); + move || pending() || matches!(value.get(), Some(Ok(_))) + }; + + // error text for name field + let name_warn_hint = MaybeProp::derive(move || { + let (name, Some(sanitized_name)) = (name.get(), sanitized_name()) else { + return None; + }; + if name != sanitized_name.clone().to_string() { + return Some(format!( + "This name will be converted to \"{sanitized_name}\"." + )); + } + None + }); + let name_error_hint = MaybeProp::derive(move || { + if let (Some(Some(Ok(false))), Some(sanitized_name)) = + (is_available_resource.get(), sanitized_name()) + { + Some(format!( + "A store named \"{sanitized_name}\" already exists in this \ + organization." + )) + } else { + None + } + }); + + let org = org_hook.key(); + let submit_action = move |_| { + submit_touched.set(true); + + // the name has been checked and is available + if !(sanitized_name().is_some() + && matches!(is_available_resource.get(), Some(Some(Ok(true))))) + { + return; + } + + let Some(credentials) = credentials() else { + return; + }; + + action.dispatch_local(CreateStore { + org: org(), + name: sanitized_name().unwrap().to_string(), + credentials, + configuration: StoreConfiguration {}, + }); + }; + + let dashboard_url = org_hook.dashboard_url(); + Effect::new(move || { + if matches!(action.value().get(), Some(Ok(_))) { + navigate_to(&dashboard_url()); + } + }); + + view! { +
+
+

"Create a Store"

+ +

{ STORE_DESCRIPTION }

+ + +
+ } +} + +#[server(prefix = "/api/sfn", input = Json)] +pub async fn create_store( + org: RecordId, + name: String, + credentials: R2StorageCredentials, + configuration: StoreConfiguration, +) -> Result, ServerFnError> { + use prime_domain::PrimeDomainService; + + crate::resources::authorize_for_org(org)?; + + let prime_domain_service: PrimeDomainService = expect_context(); + + let sanitized_name = EntityName::new(StrictSlug::new(name.clone())); + if name != sanitized_name.clone().to_string() { + return Err(ServerFnError::new("name is unsanitized")); + } + + prime_domain_service + .create_store( + org, + sanitized_name, + StorageCredentials::R2(credentials), + configuration, + ) + .await + .map_err(|e| { + tracing::error!("failed to create store: {e}"); + ServerFnError::new("internal error") + }) +} diff --git a/crates/site-app/src/pages/create_store/credentials_input.rs b/crates/site-app/src/pages/create_store/credentials_input.rs new file mode 100644 index 00000000..a312a0cf --- /dev/null +++ b/crates/site-app/src/pages/create_store/credentials_input.rs @@ -0,0 +1,109 @@ +use leptos::prelude::*; +use models::R2StorageCredentials; + +use crate::{components::InputField, reactive_utils::touched_input_bindings}; + +#[component] +pub fn CredentialsInput( + signal: RwSignal>, + show_hints: impl Fn() -> bool + Copy + Send + Sync + 'static, +) -> impl IntoView { + let access_key = RwSignal::new(String::new()); + let secret_access_key = RwSignal::new(String::new()); + let bucket = RwSignal::new(String::new()); + let endpoint = RwSignal::new(String::new()); + let (read_access_key, write_access_key) = touched_input_bindings(access_key); + let (read_secret_access_key, write_secret_access_key) = + touched_input_bindings(secret_access_key); + let (read_bucket, write_bucket) = touched_input_bindings(bucket); + let (read_endpoint, write_endpoint) = touched_input_bindings(endpoint); + + let access_key_error = Signal::derive(move || { + access_key + .get() + .is_empty() + .then_some("Access key required.".to_owned()) + }); + let secret_access_key_error = Signal::derive(move || { + secret_access_key + .get() + .is_empty() + .then_some("Secret access key required.".to_owned()) + }); + let bucket_error = Signal::derive(move || { + [ + bucket + .get() + .is_empty() + .then_some("Bucket required.".to_owned()), + (!bucket.get().is_ascii()).then_some("Bucket must be ASCII.".to_owned()), + ] + .into_iter() + .flatten() + .next() + }); + let endpoint_error = Signal::derive(move || { + endpoint + .get() + .is_empty() + .then_some("Endpoint required.".to_owned()) + }); + + let error_to_error_hint = move |e: Signal>| { + MaybeProp::derive(move || show_hints().then_some(e()).flatten()) + }; + let access_key_error_hint = error_to_error_hint(access_key_error); + let secret_access_key_error_hint = + error_to_error_hint(secret_access_key_error); + let bucket_error_hint = error_to_error_hint(bucket_error); + let endpoint_error_hint = error_to_error_hint(endpoint_error); + + let final_product = Memo::new(move |_| { + if access_key_error().is_some() + || secret_access_key_error().is_some() + || bucket_error().is_some() + || endpoint_error().is_some() + { + return None; + } + + Some(R2StorageCredentials::Default { + access_key: access_key(), + secret_access_key: secret_access_key(), + endpoint: endpoint(), + bucket: bucket(), + }) + }); + + // synchronize inputs to signal + Effect::watch( + move || final_product(), + move |final_product, _, _| signal.set(final_product.clone()), + false, + ); + + view! { +
+ + + + +
+ } +} diff --git a/crates/site-app/src/pages/dashboard/store.rs b/crates/site-app/src/pages/dashboard/store.rs index 90598c0a..d11e2283 100644 --- a/crates/site-app/src/pages/dashboard/store.rs +++ b/crates/site-app/src/pages/dashboard/store.rs @@ -3,7 +3,7 @@ use leptos_fetch::QueryClient; use models::{PvStorageCredentials, PvStore}; use crate::{ - components::{DataTableRefreshButton, StoreItemLink}, + components::{CreateStoreButton, DataTableRefreshButton, StoreItemLink}, formatting_utils::ThousandsSeparated, hooks::OrgHook, resources::store::{ @@ -42,6 +42,7 @@ pub(super) fn StoreTable() -> impl IntoView { +
From 4f153f23e0c6e689f5d2e2ec0e9855607a3e9d5e Mon Sep 17 00:00:00 2001 From: John Lewis Date: Mon, 8 Sep 2025 14:07:05 -0700 Subject: [PATCH 11/11] chore(app): satisfy clippy lints --- crates/site-app/src/pages/create_store/credentials_input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/site-app/src/pages/create_store/credentials_input.rs b/crates/site-app/src/pages/create_store/credentials_input.rs index a312a0cf..26095571 100644 --- a/crates/site-app/src/pages/create_store/credentials_input.rs +++ b/crates/site-app/src/pages/create_store/credentials_input.rs @@ -77,7 +77,7 @@ pub fn CredentialsInput( // synchronize inputs to signal Effect::watch( - move || final_product(), + final_product, move |final_product, _, _| signal.set(final_product.clone()), false, );