Requirement gains an optional `resource` (reuses ResourceSpec/ ResourceSource/jq wholesale - a resource is a resource whether it's displayed read-only or offered as choices to pick from) and `id_field` (which field in each item is its stable id, defaults to _id then id). `type: select` + `multiple` (already-existing field, previously file -only) picks single vs multi. New get_requirement_options server fn shares its auth/fetch/jq logic with get_resource via two extracted helpers rather than duplicating it. Submitted value is the selected id (single) or a JSON array of ids (multi) - a new select_field_map (RwSignal<Vec<String>>, alongside the existing field_map/file_refs maps, since a multi-select's value is a set, not a string) threaded through the same nested <For> structure the other requirement kinds already use. Content validation extended: a `type: select` requirement declaring no resource now fails at load time instead of rendering a dead field. Caught a real bug in my own first version of that check while testing it - it was nested inside a feature-level resource guard, so it never ran unless the *feature* also happened to have its own resource. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
43fa496778
commit
755b796ad1
+93
-34
@@ -23,26 +23,72 @@ pub async fn get_resource(
|
||||
feature_name: String,
|
||||
params: std::collections::HashMap<String, String>,
|
||||
) -> Result<serde_json::Value, ServerFnError> {
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
use crate::content::ResourceSource;
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
|
||||
let resource = feature
|
||||
.resource
|
||||
.as_ref()
|
||||
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
|
||||
authorize_resource(resource).await?;
|
||||
fetch_resource_value(&state, resource, ¶ms).await
|
||||
}
|
||||
|
||||
/// The `Requirement.resource`-backed counterpart to `get_resource` -
|
||||
/// same lookup/auth/fetch machinery, just resolving through a named
|
||||
/// requirement's own resource (the option source for a `type: select`
|
||||
/// field) instead of a feature's. A resource is a resource regardless
|
||||
/// of whether it's displayed read-only or offered as choices to pick
|
||||
/// from, so this deliberately doesn't duplicate the source-dispatch or
|
||||
/// jq-shaping logic - see `fetch_resource_value`.
|
||||
#[server]
|
||||
pub async fn get_requirement_options(
|
||||
question_id: String,
|
||||
alternative: String,
|
||||
feature_name: String,
|
||||
requirement_name: String,
|
||||
params: std::collections::HashMap<String, String>,
|
||||
) -> Result<serde_json::Value, ServerFnError> {
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
|
||||
let requirement = feature
|
||||
.requirements
|
||||
.iter()
|
||||
.find(|r| r.name == requirement_name)
|
||||
.ok_or_else(|| ServerFnError::new("unknown requirement"))?;
|
||||
let resource = requirement
|
||||
.resource
|
||||
.as_ref()
|
||||
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
|
||||
authorize_resource(resource).await?;
|
||||
fetch_resource_value(&state, resource, ¶ms).await
|
||||
}
|
||||
|
||||
/// Scoped to the named alternative first, not flattened across all of
|
||||
/// them - a feature name (often just "") is only unique within its own
|
||||
/// alternative, not across a whole question. Flattening silently
|
||||
/// resolved every same-named feature to whichever alternative happened
|
||||
/// to be first, so "Subscribers" (and any other later resource-listing
|
||||
/// alternative sharing an unnamed feature with an earlier one on the
|
||||
/// same question) always read the first alternative's bucket instead
|
||||
/// of its own.
|
||||
#[cfg(feature = "ssr")]
|
||||
fn find_feature(
|
||||
state: &crate::server::AppState,
|
||||
question_id: &str,
|
||||
alternative: &str,
|
||||
feature_name: &str,
|
||||
) -> Result<crate::content::Feature, ServerFnError> {
|
||||
let question = state
|
||||
.questions
|
||||
.load()
|
||||
.get(&question_id)
|
||||
.get(question_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
||||
// Scoped to the named alternative first, not flattened across all
|
||||
// of them - a feature name (often just "") is only unique within
|
||||
// its own alternative, not across a whole question. Flattening
|
||||
// silently resolved every same-named feature to whichever
|
||||
// alternative happened to be first, so "Subscribers" (and any
|
||||
// other later resource-listing alternative sharing an unnamed
|
||||
// feature with an earlier one on the same question) always read
|
||||
// the first alternative's bucket instead of its own.
|
||||
let feature = question
|
||||
question
|
||||
.alternatives
|
||||
.iter()
|
||||
.find(|a| a.name == alternative)
|
||||
@@ -50,27 +96,40 @@ pub async fn get_resource(
|
||||
.features
|
||||
.iter()
|
||||
.find(|f| f.name == feature_name)
|
||||
.ok_or_else(|| ServerFnError::new("unknown feature"))?;
|
||||
let resource = feature
|
||||
.resource
|
||||
.as_ref()
|
||||
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
|
||||
.cloned()
|
||||
.ok_or_else(|| ServerFnError::new("unknown feature"))
|
||||
}
|
||||
|
||||
if !resource.public {
|
||||
let group = resource
|
||||
.requires_group
|
||||
.as_deref()
|
||||
.ok_or_else(|| ServerFnError::new("resource is not accessible"))?;
|
||||
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||
let user = session
|
||||
.get::<User>(SESSION_USER_KEY)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new("not signed in"))?;
|
||||
if !user.groups.iter().any(|g| g == group) {
|
||||
return Err(ServerFnError::new("not authorized"));
|
||||
}
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn authorize_resource(resource: &crate::content::ResourceSpec) -> Result<(), ServerFnError> {
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
|
||||
if resource.public {
|
||||
return Ok(());
|
||||
}
|
||||
let group = resource
|
||||
.requires_group
|
||||
.as_deref()
|
||||
.ok_or_else(|| ServerFnError::new("resource is not accessible"))?;
|
||||
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||
let user = session
|
||||
.get::<User>(SESSION_USER_KEY)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new("not signed in"))?;
|
||||
if !user.groups.iter().any(|g| g == group) {
|
||||
return Err(ServerFnError::new("not authorized"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn fetch_resource_value(
|
||||
state: &crate::server::AppState,
|
||||
resource: &crate::content::ResourceSpec,
|
||||
params: &std::collections::HashMap<String, String>,
|
||||
) -> Result<serde_json::Value, ServerFnError> {
|
||||
use crate::content::ResourceSource;
|
||||
|
||||
let value = match &resource.source {
|
||||
ResourceSource::Kv { bucket } => {
|
||||
@@ -110,12 +169,12 @@ pub async fn get_resource(
|
||||
}
|
||||
}
|
||||
ResourceSource::GiteaStarred { username } => {
|
||||
fetch_gitea_json(&state, &format!("/api/v1/users/{username}/starred"), ¶ms).await?
|
||||
fetch_gitea_json(state, &format!("/api/v1/users/{username}/starred"), params).await?
|
||||
}
|
||||
ResourceSource::GiteaOrgRepos { org } => {
|
||||
fetch_gitea_json(&state, &format!("/api/v1/orgs/{org}/repos"), ¶ms).await?
|
||||
fetch_gitea_json(state, &format!("/api/v1/orgs/{org}/repos"), params).await?
|
||||
}
|
||||
ResourceSource::Url { url } => fetch_url_json(url, ¶ms).await?,
|
||||
ResourceSource::Url { url } => fetch_url_json(url, params).await?,
|
||||
};
|
||||
|
||||
match &resource.jq {
|
||||
|
||||
Reference in New Issue
Block a user