Add self-service transitions, authorized by item possession not group membership
Deploy / deploy (push) Successful in 33s
Deploy / deploy (push) Successful in 33s
New Alternative.self_transition: like ResourceSpec.transitions, but for an anonymous visitor holding one specific item's own chain hash (from a ?chain= link, the same reference /subscribed?chain=... already carries) rather than a signed-in owner browsing a whole bucket. email is a second factor checked against the stored item, not the lookup key - defense in depth against a leaked/guessed chain hash alone. AlternativeCard renders it as a single confirm button when both ?chain= and ?email= are present; QuestionView hides the alternative entirely otherwise, rather than showing a dead card with nothing to click. Powers /subscribed's new "Unsubscribe" alternative, which the newsletter's own recipient links now carry - no email lookup needed, just the same chain_hash the subscription itself already produced.
This commit is contained in:
+76
-2
@@ -7,7 +7,7 @@ use leptos_router::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::answers::{Answer, TransitionAnswer};
|
||||
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswer};
|
||||
use crate::auth::{current_user, User};
|
||||
use crate::content::{is_qualified, Alternative, Question, Transition};
|
||||
use crate::resource::get_resource;
|
||||
@@ -73,6 +73,7 @@ fn QuestionPage() -> impl IntoView {
|
||||
}
|
||||
});
|
||||
let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain")));
|
||||
let query_email = Memo::new(move |_| query.with(|q| q.get("email")));
|
||||
|
||||
let question = Resource::new(move || path.get(), get_question);
|
||||
let user = Resource::new(|| (), |_| current_user());
|
||||
@@ -100,6 +101,7 @@ fn QuestionPage() -> impl IntoView {
|
||||
<QuestionView
|
||||
question=q
|
||||
parent_hash=parent_hash.get()
|
||||
query_email=query_email.get()
|
||||
user=current
|
||||
/>
|
||||
}
|
||||
@@ -116,6 +118,7 @@ fn QuestionPage() -> impl IntoView {
|
||||
fn QuestionView(
|
||||
question: Question,
|
||||
parent_hash: Option<String>,
|
||||
query_email: Option<String>,
|
||||
user: Option<User>,
|
||||
) -> impl IntoView {
|
||||
let question_id = question.id.clone();
|
||||
@@ -162,7 +165,24 @@ fn QuestionView(
|
||||
/>
|
||||
<div class="alternatives">
|
||||
<For
|
||||
each=move || question.alternatives.clone()
|
||||
each={
|
||||
let parent_hash = parent_hash.clone();
|
||||
let query_email = query_email.clone();
|
||||
move || {
|
||||
// A self_transition alternative has nothing to show
|
||||
// (no action to submit, no button target) without
|
||||
// both link params present - hide it rather than
|
||||
// render a dead-looking empty card for anyone who
|
||||
// reaches this question the normal way.
|
||||
let has_link = parent_hash.is_some() && query_email.is_some();
|
||||
question
|
||||
.alternatives
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter(|a| a.self_transition.is_none() || has_link)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
key=|a| a.name.clone()
|
||||
children=move |alt: Alternative| {
|
||||
view! {
|
||||
@@ -170,6 +190,7 @@ fn QuestionView(
|
||||
question_id=question_id.clone()
|
||||
alternative=alt
|
||||
parent_hash=parent_hash.clone()
|
||||
query_email=query_email.clone()
|
||||
/>
|
||||
}
|
||||
.into_any()
|
||||
@@ -298,7 +319,59 @@ fn AlternativeCard(
|
||||
question_id: String,
|
||||
alternative: Alternative,
|
||||
parent_hash: Option<String>,
|
||||
query_email: Option<String>,
|
||||
) -> impl IntoView {
|
||||
// A link carrying both ?chain= and ?email= against an alternative
|
||||
// that declares a self_transition renders as a one-button
|
||||
// confirmation instead of the normal form - the visitor already
|
||||
// holds everything the action needs (see
|
||||
// `answers::self_transition_answer`), there's nothing left to type.
|
||||
if let (Some(st), Some(item_id), Some(email)) =
|
||||
(alternative.self_transition.clone(), parent_hash.clone(), query_email)
|
||||
{
|
||||
let self_transition = ServerAction::<SelfTransitionAnswer>::new();
|
||||
let question_id_for_action = question_id.clone();
|
||||
let alt_name_for_action = alternative.name.clone();
|
||||
let item_id_for_action = item_id.clone();
|
||||
let email_for_action = email.clone();
|
||||
let label = st.label.clone();
|
||||
return view! {
|
||||
<section class="alt-card">
|
||||
<h2>{alternative.name.clone()}</h2>
|
||||
<p class="alt-description">{alternative.description.clone()}</p>
|
||||
{move || {
|
||||
let question_id_for_action = question_id_for_action.clone();
|
||||
let alt_name_for_action = alt_name_for_action.clone();
|
||||
let item_id_for_action = item_id_for_action.clone();
|
||||
let email_for_action = email_for_action.clone();
|
||||
let label = label.clone();
|
||||
match self_transition.value().get() {
|
||||
None => view! {
|
||||
<button
|
||||
class="alt-submit"
|
||||
disabled=move || self_transition.pending().get()
|
||||
on:click=move |_| {
|
||||
self_transition.dispatch(SelfTransitionAnswer {
|
||||
question_id: question_id_for_action.clone(),
|
||||
alternative: alt_name_for_action.clone(),
|
||||
item_id: item_id_for_action.clone(),
|
||||
email: email_for_action.clone(),
|
||||
});
|
||||
}
|
||||
>
|
||||
{label.clone()}
|
||||
</button>
|
||||
}
|
||||
.into_any(),
|
||||
Some(Ok(())) => view! { <p>"Done."</p> }.into_any(),
|
||||
Some(Err(e)) => view! { <p class="resource-error">{e.to_string()}</p> }.into_any(),
|
||||
}
|
||||
}}
|
||||
</section>
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
|
||||
let has_action = alternative.action.is_some();
|
||||
|
||||
// File fields don't fit a live-typed RwSignal<String> - they get
|
||||
@@ -596,6 +669,7 @@ fn AlternativeCard(
|
||||
})}
|
||||
</section>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
/// Fetches and renders a `Feature`'s `resource` (`content::ResourceSpec`).
|
||||
|
||||
Reference in New Issue
Block a user