Implement template student portal #62
No files matched your search
Generated
+722
-9
File diff suppressed because it is too large.
Load diff
+6
-1
@@ -7,7 +7,7 @@ edition = "2024"
|
|||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
leptos = { version = "0.8" }
|
leptos = { version = "0.8", features = ["multipart"] }
|
||||||
leptos_router = { version = "0.8" }
|
leptos_router = { version = "0.8" }
|
||||||
axum = { version = "0.8", optional = true }
|
axum = { version = "0.8", optional = true }
|
||||||
console_error_panic_hook = { version = "0.1", optional = true}
|
console_error_panic_hook = { version = "0.1", optional = true}
|
||||||
@@ -16,6 +16,7 @@ leptos_meta = { version = "0.8" }
|
|||||||
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
||||||
wasm-bindgen = { version = "0.2", optional = true }
|
wasm-bindgen = { version = "0.2", optional = true }
|
||||||
aws-sdk-dynamodb = { version = "^1", optional = true }
|
aws-sdk-dynamodb = { version = "^1", optional = true }
|
||||||
|
aws-sdk-s3 = { version = "1.124.0", optional = true }
|
||||||
aws-config = { version = "^1.8", optional = true }
|
aws-config = { version = "^1.8", optional = true }
|
||||||
serde = "^1.0"
|
serde = "^1.0"
|
||||||
serde_dynamo = { version = "^4", features = ["aws-sdk-dynamodb+1"], optional = true }
|
serde_dynamo = { version = "^4", features = ["aws-sdk-dynamodb+1"], optional = true }
|
||||||
@@ -32,6 +33,8 @@ leptos_icons = "0.7.1"
|
|||||||
indexmap = { version = "2.13.0", features = ["serde"] }
|
indexmap = { version = "2.13.0", features = ["serde"] }
|
||||||
csv = "1.4.0"
|
csv = "1.4.0"
|
||||||
leptos-use = { version = "0.18.3", features = ["use_drop_zone"] }
|
leptos-use = { version = "0.18.3", features = ["use_drop_zone"] }
|
||||||
|
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"], optional = true }
|
||||||
|
reqwest = { version = "0.13.2", features = ["json"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["ssr"]
|
default = ["ssr"]
|
||||||
@@ -45,8 +48,10 @@ ssr = [
|
|||||||
"dep:tokio",
|
"dep:tokio",
|
||||||
"dep:leptos_axum",
|
"dep:leptos_axum",
|
||||||
"dep:aws-sdk-dynamodb",
|
"dep:aws-sdk-dynamodb",
|
||||||
|
"dep:aws-sdk-s3",
|
||||||
"dep:aws-config",
|
"dep:aws-config",
|
||||||
"dep:serde_dynamo",
|
"dep:serde_dynamo",
|
||||||
|
"dep:jsonwebtoken",
|
||||||
"leptos/ssr",
|
"leptos/ssr",
|
||||||
"leptos_meta/ssr",
|
"leptos_meta/ssr",
|
||||||
"leptos_router/ssr",
|
"leptos_router/ssr",
|
||||||
|
|||||||
@@ -22,4 +22,6 @@ pub struct UserClaims {
|
|||||||
/// The username of the user. This should be their email address.
|
/// The username of the user. This should be their email address.
|
||||||
#[serde(rename = "username")]
|
#[serde(rename = "username")]
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
/// The intended use for the token.
|
||||||
|
pub token_use: String,
|
||||||
}
|
}
|
||||||
+101
-29
@@ -1,53 +1,125 @@
|
|||||||
use leptos::html::Div;
|
use leptos::html::{Div, Input, Label};
|
||||||
use leptos::logging::debug_log;
|
use leptos::logging::debug_log;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use leptos::web_sys::{File, FormData};
|
||||||
|
use leptos_oidc::AuthSignal;
|
||||||
use leptos_use::{UseDropZoneOptions, UseDropZoneReturn, use_drop_zone_with_options};
|
use leptos_use::{UseDropZoneOptions, UseDropZoneReturn, use_drop_zone_with_options};
|
||||||
|
use crate::pages::api::files::upload_file;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn FileDrop() -> impl IntoView {
|
pub fn FileDrop(
|
||||||
// I'm really not sure how to hydrate this, but we'll try our best to do it somehow.
|
#[prop(into)] name: String,
|
||||||
// We just need to know the file names and the size, not actually have the file itself.
|
#[prop(into)] form_id: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let zone_ref = NodeRef::<Label>::new();
|
||||||
|
let input_ref = NodeRef::<Input>::new();
|
||||||
|
|
||||||
let zone_ref = NodeRef::<Div>::new();
|
let file_name_list = RwSignal::new(Vec::new());
|
||||||
|
let hovering = RwSignal::new(false);
|
||||||
|
let name = StoredValue::new(name);
|
||||||
|
let form_id = StoredValue::new(form_id);
|
||||||
|
let auth = expect_context::<AuthSignal>();
|
||||||
|
|
||||||
let file_list = RwSignal::new(Vec::new());
|
let upload_action = StoredValue::new(Action::new(|form_data: &FormData| {
|
||||||
|
let form_data = form_data.clone();
|
||||||
|
async move {
|
||||||
|
upload_file(form_data.into()).await
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let uploading = upload_action.get_value().pending();
|
||||||
|
|
||||||
|
let upload_files = Callback::new(move |files: Vec<File>| {
|
||||||
|
let form_data = FormData::new().unwrap();
|
||||||
|
|
||||||
|
files.iter().for_each(|file| {
|
||||||
|
let file_name = format!("{}_{}", name.get_value(), file.name());
|
||||||
|
|
||||||
|
form_data.append_with_blob_and_filename(&name.get_value(), file, &file_name)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
form_data.append_with_str("form_id", form_id.get_value().as_str()).unwrap();
|
||||||
|
|
||||||
|
let token = auth.try_with(|a|
|
||||||
|
a.authenticated().map(|authenticated| authenticated.access_token())
|
||||||
|
).flatten();
|
||||||
|
|
||||||
|
if let Some(access_token) = token {
|
||||||
|
form_data.append_with_str("access_token", access_token.as_str()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload file to server.
|
||||||
|
upload_action.get_value().dispatch(form_data);
|
||||||
|
});
|
||||||
|
|
||||||
// Files are just the current files that are being processed, not the full list of files that
|
|
||||||
// have been dropped. So, if the user drops multiple files, it will be the list of those files,
|
|
||||||
// but it will wipe the previous set of dropped files.
|
|
||||||
let UseDropZoneReturn {
|
let UseDropZoneReturn {
|
||||||
is_over_drop_zone,
|
is_over_drop_zone,
|
||||||
files,
|
..
|
||||||
} = use_drop_zone_with_options(
|
} = use_drop_zone_with_options(
|
||||||
zone_ref,
|
zone_ref,
|
||||||
UseDropZoneOptions::default()
|
UseDropZoneOptions::default()
|
||||||
.on_over(|_| debug_log!("File is over the drop zone!"))
|
.on_over(move |_| hovering.set(true))
|
||||||
.on_drop(move |mut ev| {
|
.on_drop(move |ev| {
|
||||||
file_list.update(|list| {
|
let file = ev.files.first()
|
||||||
list.extend(ev.files);
|
.unwrap();
|
||||||
|
|
||||||
|
// Update file list
|
||||||
|
file_name_list.update(|list| {
|
||||||
|
list.push(file.name());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
upload_files.run(vec![file.to_owned()]);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
Effect::new(move || {
|
let on_input_change = move |_| {
|
||||||
let file_names = file_list
|
debug_log!("Running on:change");
|
||||||
.get()
|
|
||||||
.iter()
|
|
||||||
.map(|file| file.name())
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
debug_log!("Updated file list. Current file names: {}", file_names);
|
// This runs when users select a file by clicking on the element. We aren't going
|
||||||
});
|
// to link the file list with this input except to update it with new information.
|
||||||
|
let files = input_ref.get()
|
||||||
|
.and_then(|input| input.files())
|
||||||
|
.map(|list| {
|
||||||
|
(0..list.length())
|
||||||
|
.filter_map(|i| list.item(i))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
file_name_list.update({
|
||||||
|
let files = files.clone();
|
||||||
|
|list| {
|
||||||
|
for file in files {
|
||||||
|
if !list.iter().any(|f| *f == file.name()) {
|
||||||
|
list.push(file.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
upload_files.run(files);
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<label
|
||||||
node_ref=zone_ref
|
node_ref=zone_ref
|
||||||
class="size-64"
|
class="m-1.5 p-2.5 flex flex-col transition-color duration-200 rounded-lg border-2 items-center cursor-pointer"
|
||||||
class=(["bg-blue-500"], move || is_over_drop_zone.get())
|
class=(["border-gray-500"], move || !is_over_drop_zone.get())
|
||||||
/>
|
class=(["border-blue-500"], move || is_over_drop_zone.get())
|
||||||
<For each=move || file_list.get() key=|file| file.name() let:file>
|
>
|
||||||
<div>"File name: "{file.name()}</div>
|
<div>
|
||||||
|
"Drop a file to upload"
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
node_ref=input_ref
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
on:change=on_input_change
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<For each=move || file_name_list.get() key=|file| file.clone() let:file>
|
||||||
|
<div>"File name: "{file}</div>
|
||||||
</For>
|
</For>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::common::{DateInfo, DateRange};
|
use crate::common::{DateInfo, DateRange};
|
||||||
use crate::components::{ActionButton, FileDrop};
|
use crate::components::ActionButton;
|
||||||
use crate::pages::api::exports::get_scholarship_csv;
|
use crate::pages::api::exports::get_scholarship_csv;
|
||||||
use crate::pages::api::{CreateDates, CreateTestComparisons, exports::GetScholarshipCsv};
|
use crate::pages::api::{CreateDates, CreateTestComparisons, exports::GetScholarshipCsv};
|
||||||
use chrono::DateTime;
|
use chrono::DateTime;
|
||||||
@@ -109,7 +109,6 @@ pub fn AdminUtilsPage() -> impl IntoView {
|
|||||||
<ActionButton on:click=on_click_comparisons>"Create Comparisons"</ActionButton>
|
<ActionButton on:click=on_click_comparisons>"Create Comparisons"</ActionButton>
|
||||||
<ActionButton on:click=on_click_dates>"Create Dates"</ActionButton>
|
<ActionButton on:click=on_click_dates>"Create Dates"</ActionButton>
|
||||||
<ActionButton on:click=on_click_export>"Scholarship Export"</ActionButton>
|
<ActionButton on:click=on_click_export>"Scholarship Export"</ActionButton>
|
||||||
<FileDrop />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use server_fn::codec::{MultipartFormData, MultipartData};
|
||||||
|
use leptos::logging::debug_log;
|
||||||
|
|
||||||
|
#[server(input = MultipartFormData)]
|
||||||
|
pub async fn upload_file(
|
||||||
|
data: MultipartData
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use crate::pages::api::tokens::validate_and_get_token_info;
|
||||||
|
use crate::utils::server::create_aws_config;
|
||||||
|
|
||||||
|
let mut multipart = data.into_inner().unwrap();
|
||||||
|
|
||||||
|
let mut file_name = String::new();
|
||||||
|
let mut file_bytes = Vec::<u8>::new();
|
||||||
|
let mut form_id = String::new();
|
||||||
|
let mut access_token = String::new();
|
||||||
|
|
||||||
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
|
let field_name = field.name().unwrap_or_default();
|
||||||
|
|
||||||
|
match field_name {
|
||||||
|
"access_token" => access_token = field.text().await?,
|
||||||
|
"form_id" => form_id = field.text().await?,
|
||||||
|
_ => {
|
||||||
|
// NOTE: If there are multiple files put here the function will only upload the last
|
||||||
|
// one in the data.
|
||||||
|
// We should likely use chunk streaming in the future, for safety.
|
||||||
|
file_name = field.file_name()
|
||||||
|
.ok_or(ServerFnError::new("Couldn't find file name"))?
|
||||||
|
.to_owned();
|
||||||
|
file_bytes = field.bytes().await?.to_vec();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if access_token.is_empty() { return Err(ServerFnError::new("Missing access token")); }
|
||||||
|
if form_id.is_empty() { return Err(ServerFnError::new("Missing form ID")); }
|
||||||
|
if file_name.is_empty() { return Err(ServerFnError::new("Missing file")); }
|
||||||
|
|
||||||
|
let user_claims = validate_and_get_token_info(access_token).await?;
|
||||||
|
let subject = user_claims.subject;
|
||||||
|
|
||||||
|
let key = format!("{form_id}/{subject}/{file_name}");
|
||||||
|
|
||||||
|
debug_log!("Adding file to S3: {:?}", key);
|
||||||
|
|
||||||
|
let client = aws_sdk_s3::Client::new(&create_aws_config().await);
|
||||||
|
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket("leptos-scholarships")
|
||||||
|
.key(key)
|
||||||
|
.body(file_bytes.into())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(ServerFnError::from)
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ pub mod exports;
|
|||||||
mod providers;
|
mod providers;
|
||||||
mod scholarships;
|
mod scholarships;
|
||||||
pub mod students;
|
pub mod students;
|
||||||
|
pub mod files;
|
||||||
|
mod tokens;
|
||||||
|
|
||||||
pub use admin::*;
|
pub use admin::*;
|
||||||
pub use comparisons::*;
|
pub use comparisons::*;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use jsonwebtoken::{jwk::JwkSet, DecodingKey};
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use crate::common::UserClaims;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub async fn fetch_jwks(user_pool_id: String, region: String) -> Result<JwkSet, ServerFnError> {
|
||||||
|
let request_url = format!(
|
||||||
|
"https://cognito-idp.{}.amazonaws.com/{}/.well-known/jwks.json",
|
||||||
|
region,
|
||||||
|
user_pool_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let jwks = reqwest::get(&request_url).await?
|
||||||
|
.json::<JwkSet>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(jwks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a JWT, and, if successful, decodes it into a series of user claims.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub async fn validate_and_get_token_info(token: String) -> Result<UserClaims, ServerFnError> {
|
||||||
|
use jsonwebtoken::{decode, Algorithm, Validation};
|
||||||
|
|
||||||
|
let jwks = fetch_jwks("us-east-1_Lfjuy5zaM".into(), "us-east-1".into()).await?;
|
||||||
|
|
||||||
|
let validation = Validation::new(Algorithm::RS256);
|
||||||
|
// validation.set_audience(&["scholarships-rs"]);
|
||||||
|
|
||||||
|
let header = jsonwebtoken::decode_header(&token)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Invalid token header: {e}")))?;
|
||||||
|
|
||||||
|
let kid = header.kid.ok_or_else(|| ServerFnError::new("Missing kid header"))?;
|
||||||
|
|
||||||
|
let jwk = jwks.find(&kid)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Couldn't find matching key in JWKS"))?;
|
||||||
|
|
||||||
|
let token_data = decode::<UserClaims>(
|
||||||
|
&token,
|
||||||
|
&DecodingKey::from_jwk(&jwk)?,
|
||||||
|
&validation
|
||||||
|
).map_err(|_| ServerFnError::new("Invalid token found".to_string()))?;
|
||||||
|
|
||||||
|
if token_data.claims.token_use != "access" {
|
||||||
|
return Err(ServerFnError::new("Token type mismatch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(token_data.claims)
|
||||||
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
mod about_page;
|
mod about_page;
|
||||||
mod api;
|
pub mod api;
|
||||||
mod comparison_test;
|
mod comparison_test;
|
||||||
mod home_page;
|
mod home_page;
|
||||||
mod loaner_page;
|
mod loaner_page;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use crate::components::FileDrop;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AdditionalPage() -> impl IntoView {
|
pub fn AdditionalPage() -> impl IntoView {
|
||||||
@@ -8,6 +9,8 @@ pub fn AdditionalPage() -> impl IntoView {
|
|||||||
<div class="text-lg">
|
<div class="text-lg">
|
||||||
"This is just a placeholder for now. We'll show redirect buttons to each form."
|
"This is just a placeholder for now. We'll show redirect buttons to each form."
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-lg">"Here's a testing file input!"</div>
|
||||||
|
<FileDrop name="testing" form_id="student_additional"/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user