diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..4fd8b59e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,94 @@ +# Created by .ignore support plugin (hsz.mobi) +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Rust template +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +/tmp/ + +/jirs-client/target/ +/jirs-client/tmp/ +/jirs-client/build/ + +/jirs-server/target/ +/jirs-server/tmp/ diff --git a/.gitignore b/.gitignore index 5eb81521..1381a833 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,7 @@ db.toml db.test.toml pkg jirs-client/pkg +jirs-client/tmp +jirs-client/build tmp +jirs-server/target diff --git a/docker-compose.yml b/docker-compose.yml index 95e75d62..b933059a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,11 @@ -version: '3.0' - -services: - db: - image: postgres:latest - ports: - - 5432:5432 +version: '3.0' + +services: + db: + image: postgres:latest + ports: + - 5432:5432 + server: + build: + dockerfile: ./jirs-server/Dockerfile + context: . diff --git a/jirs-client/.gitignore b/jirs-client/.gitignore index af553c01..08497322 100644 --- a/jirs-client/.gitignore +++ b/jirs-client/.gitignore @@ -5,3 +5,4 @@ dist tmp dev/styles.css build +target diff --git a/jirs-client/js/css/invite.css b/jirs-client/js/css/invite.css new file mode 100644 index 00000000..1851df0b --- /dev/null +++ b/jirs-client/js/css/invite.css @@ -0,0 +1,20 @@ +#invite > .styledForm { + display: flex; + flex-direction: column; + margin: 0 auto 24px; + width: 400px; + background: rgb(255, 255, 255) none repeat scroll 0 0; + border-radius: 3px; + box-shadow: rgba(0, 0, 0, 0.1) 0 0 10px; + box-sizing: border-box; + color: var(--textMedium); +} + +#invite > .styledForm:first-of-type { + margin-top: 124.5px; + margin-bottom: 0; +} + +#invite > .styledForm:last-of-type { + box-shadow: rgba(0, 0, 0, 0.1) 0 10px 10px; +} diff --git a/jirs-client/js/styles.css b/jirs-client/js/styles.css index 7a4eba39..f87e6274 100644 --- a/jirs-client/js/styles.css +++ b/jirs-client/js/styles.css @@ -30,3 +30,4 @@ @import "./css/login.css"; @import "./css/register.css"; @import "./css/users.css"; +@import "./css/invite.css"; diff --git a/jirs-client/scripts/dev.sh b/jirs-client/scripts/dev.sh index 478b48ef..bf937727 100755 --- a/jirs-client/scripts/dev.sh +++ b/jirs-client/scripts/dev.sh @@ -3,9 +3,10 @@ . .env rm -Rf tmp -mkdir tmp +mkdir -p tmp +mkdir -p target -wasm-pack build --mode normal --dev --out-name jirs --out-dir ./tmp --target web +wasm-pack build --mode normal --dev --out-name jirs --out-dir ./tmp --target web -- --verbose ../target/debug/jirs-css -i ./js/styles.css -O ./tmp/styles.css cp -r ./static/* ./tmp diff --git a/jirs-client/src/changes.rs b/jirs-client/src/changes.rs index f0f17000..45866731 100644 --- a/jirs-client/src/changes.rs +++ b/jirs-client/src/changes.rs @@ -50,12 +50,18 @@ pub enum ProfilePageChange { SubmitForm, } +#[derive(Clone, Debug, PartialEq)] +pub enum InvitationPageChange { + SubmitForm, +} + #[derive(Clone, Debug, PartialEq)] pub enum PageChanged { Users(UsersPageChange), ProjectSettings(ProjectPageChange), Profile(ProfilePageChange), Board(BoardPageChange), + Invitation(InvitationPageChange), } #[derive(Debug)] diff --git a/jirs-client/src/invite.rs b/jirs-client/src/invite.rs index 7b8008d3..593eb25e 100644 --- a/jirs-client/src/invite.rs +++ b/jirs-client/src/invite.rs @@ -1,34 +1,61 @@ +use std::str::FromStr; + use seed::{prelude::*, *}; -use jirs_data::InviteFieldId; +use jirs_data::{InviteFieldId, WsMsg}; use crate::model::{InvitePage, Model, Page, PageContent}; +use crate::shared::styled_button::StyledButton; use crate::shared::styled_field::StyledField; use crate::shared::styled_form::StyledForm; use crate::shared::styled_input::StyledInput; use crate::shared::{outer_layout, ToNode}; use crate::validations::is_token; -use crate::{FieldId, Msg}; +use crate::ws::send_ws_msg; +use crate::{FieldId, InvitationPageChange, Msg, PageChanged, WebSocketChanged}; -pub fn update(msg: Msg, model: &mut Model, _orders: &mut impl Orders) { - if let Msg::ChangePage(Page::Project) = msg { - build_page_content(model); - return; - } +pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { + match model.page_content { + PageContent::Invite(..) => (), + _ if model.page == Page::Invite => build_page_content(model), + _ => (), + }; let page = match &mut model.page_content { PageContent::Invite(page) => page, _ => return, }; - if let Msg::StrInputChanged(FieldId::Invite(InviteFieldId::Token), text) = msg { - page.token_touched = true; - page.token = text; + match msg { + Msg::WebSocketChange(WebSocketChanged::WsMsg(WsMsg::InvitationAcceptFailure(_))) => { + page.error = Some("Invalid token".to_string()); + } + Msg::StrInputChanged(FieldId::Invite(InviteFieldId::Token), text) => { + page.token_touched = true; + page.token = text; + } + Msg::PageChanged(PageChanged::Invitation(InvitationPageChange::SubmitForm)) => { + if let Ok(token) = uuid::Uuid::from_str(page.token.as_str()) { + send_ws_msg( + WsMsg::InvitationAcceptRequest(token), + model.ws.as_ref(), + orders, + ); + page.error = None; + } + } + _ => {} } } fn build_page_content(model: &mut Model) { - model.page_content = PageContent::Invite(Box::new(InvitePage::default())); + let s: String = seed::document().location().unwrap().to_string().into(); + let url = seed::Url::from_str(s.as_str()).unwrap(); + let search = url.search(); + let values = search.get("token").map(|v| v.clone()).unwrap_or_default(); + let mut content = InvitePage::default(); + content.token = values.get(0).map(|s| s.clone()).unwrap_or_default(); + model.page_content = PageContent::Invite(Box::new(content)); } pub fn view(model: &Model) -> Node { @@ -37,21 +64,46 @@ pub fn view(model: &Model) -> Node { _ => return empty![], }; - let token = StyledInput::build(FieldId::Invite(InviteFieldId::Token)) - .valid(!page.token_touched || is_token(page.token.as_str())) - .build() - .into_node(); - let token_field = StyledField::build() - .input(token) - .label("Your invite token") - .build() - .into_node(); + let token_field = token_field(page); + let submit_field = submit(page); + let error = match page.error.as_ref() { + Some(s) => div![class!["error"], s.as_str()], + _ => empty![], + }; let form = StyledForm::build() .heading("Welcome in JIRS") + .on_submit(ev(Ev::Submit, move |ev| { + ev.prevent_default(); + Msg::PageChanged(PageChanged::Invitation(InvitationPageChange::SubmitForm)) + })) .add_field(token_field) + .add_field(submit_field) + .add_field(error) .build() .into_node(); outer_layout(model, "invite", vec![form]) } + +fn submit(_page: &Box) -> Node { + let submit = StyledButton::build() + .text("Accept") + .primary() + .build() + .into_node(); + StyledField::build().input(submit).build().into_node() +} + +fn token_field(page: &Box) -> Node { + let token = StyledInput::build(FieldId::Invite(InviteFieldId::Token)) + .valid(!page.token_touched || is_token(page.token.as_str())) + .value(page.token.as_str()) + .build() + .into_node(); + StyledField::build() + .input(token) + .label("Your invite token") + .build() + .into_node() +} diff --git a/jirs-client/src/lib.rs b/jirs-client/src/lib.rs index af8ccffc..b3349838 100644 --- a/jirs-client/src/lib.rs +++ b/jirs-client/src/lib.rs @@ -53,7 +53,7 @@ pub enum Msg { InviteRequest, InviteRevokeRequest(InvitationId), InviteApproveRequest(InvitationId), - InvitedUserRemove(EmailString), + InvitedUserRemove(UserId), // sign up SignUpRequest, diff --git a/jirs-client/src/modal/mod.rs b/jirs-client/src/modal/mod.rs index 82677c86..ba8c9fd7 100644 --- a/jirs-client/src/modal/mod.rs +++ b/jirs-client/src/modal/mod.rs @@ -62,6 +62,11 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders model.modals.push(ModalType::DebugModal); } + #[cfg(debug_assertions)] + Msg::GlobalKeyDown { key, .. } if key.eq(">") => { + log!(model); + } + _ => (), } add_issue::update(msg, model, orders); diff --git a/jirs-client/src/model.rs b/jirs-client/src/model.rs index 6385139e..eb2e9257 100644 --- a/jirs-client/src/model.rs +++ b/jirs-client/src/model.rs @@ -262,6 +262,7 @@ pub struct ProjectPage { pub struct InvitePage { pub token: String, pub token_touched: bool, + pub error: Option, } #[derive(Debug)] diff --git a/jirs-client/src/shared/mod.rs b/jirs-client/src/shared/mod.rs index 8f9531b9..23fdc7a9 100644 --- a/jirs-client/src/shared/mod.rs +++ b/jirs-client/src/shared/mod.rs @@ -77,10 +77,12 @@ pub fn inner_layout( ] } -pub fn outer_layout(_model: &Model, page_name: &str, children: Vec>) -> Node { +pub fn outer_layout(model: &Model, page_name: &str, children: Vec>) -> Node { + let modal = crate::modal::view(model); article![ class!["outer-layout", "outerPage"], id![page_name], + modal, children ] } diff --git a/jirs-client/src/users/update.rs b/jirs-client/src/users/update.rs index 95058448..c3d50903 100644 --- a/jirs-client/src/users/update.rs +++ b/jirs-client/src/users/update.rs @@ -45,11 +45,11 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { } send_ws_msg(WsMsg::InvitationListRequest, model.ws.as_ref(), orders); } - WebSocketChanged::WsMsg(WsMsg::InvitedUserRemoveSuccess(email)) => { + WebSocketChanged::WsMsg(WsMsg::InvitedUserRemoveSuccess(removed_id)) => { let mut old = vec![]; std::mem::swap(&mut page.invited_users, &mut old); for user in old { - if user.email != email { + if user.id != removed_id { page.invited_users.push(user); } } @@ -110,9 +110,9 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { orders, ); } - Msg::InvitedUserRemove(email) => { + Msg::InvitedUserRemove(user_id) => { send_ws_msg( - WsMsg::InvitedUserRemoveRequest(email), + WsMsg::InvitedUserRemoveRequest(user_id), model.ws.as_ref(), orders, ); diff --git a/jirs-client/src/users/view.rs b/jirs-client/src/users/view.rs index 0c953ec4..d428750a 100644 --- a/jirs-client/src/users/view.rs +++ b/jirs-client/src/users/view.rs @@ -108,10 +108,12 @@ pub fn view(model: &Model) -> Node { .invited_users .iter() .map(|user| { - let email = user.email.clone(); + let user_id = user.id; let remove = StyledButton::build() .text("Remove") - .on_click(mouse_ev(Ev::Click, move |_| Msg::InvitedUserRemove(email))) + .on_click(mouse_ev(Ev::Click, move |_| { + Msg::InvitedUserRemove(user_id) + })) .build() .into_node(); let role = page diff --git a/jirs-data/src/lib.rs b/jirs-data/src/lib.rs index 7364fd10..dba2ec38 100644 --- a/jirs-data/src/lib.rs +++ b/jirs-data/src/lib.rs @@ -31,6 +31,8 @@ pub type MessageId = i32; pub type EmailString = String; pub type UsernameString = String; pub type TitleString = String; +pub type BindToken = Uuid; +pub type InvitationToken = Uuid; #[cfg_attr(feature = "backend", derive(FromSqlRow, AsExpression))] #[cfg_attr(feature = "backend", sql_type = "IssueTypeType")] @@ -705,10 +707,11 @@ pub enum WsMsg { InvitationRevokeRequest(InvitationId), InvitationRevokeSuccess(InvitationId), // - InvitationAcceptRequest(InvitationId), - InvitationAcceptSuccess(InvitationId), - InvitedUserRemoveRequest(EmailString), - InvitedUserRemoveSuccess(EmailString), + InvitationAcceptRequest(InvitationToken), + InvitationAcceptSuccess(BindToken), + InvitationAcceptFailure(InvitationToken), + InvitedUserRemoveRequest(UserId), + InvitedUserRemoveSuccess(UserId), // project page ProjectRequest, diff --git a/jirs-server/Dockerfile b/jirs-server/Dockerfile new file mode 100644 index 00000000..de2d56de --- /dev/null +++ b/jirs-server/Dockerfile @@ -0,0 +1,15 @@ +FROM archlinux:latest + +WORKDIR /app/ + +RUN pacman -Sy rustup gcc postgresql --noconfirm + +ADD jirs-server . +ADD jirs-data . + +RUN rustup toolchain install nightly && \ + rustup default nightly && \ + cargo install diesel_cli --no-default-features --features postgres && \ + cd jirs-server && diesel setup + +CMD cd jirs-server && cargo run --bin jirs_server diff --git a/jirs-server/src/db/invitations.rs b/jirs-server/src/db/invitations.rs index 516f41b2..131f0115 100644 --- a/jirs-server/src/db/invitations.rs +++ b/jirs-server/src/db/invitations.rs @@ -3,8 +3,8 @@ use diesel::pg::Pg; use diesel::prelude::*; use jirs_data::{ - EmailString, Invitation, InvitationId, InvitationState, ProjectId, User, UserId, UserRole, - UsernameString, + EmailString, Invitation, InvitationId, InvitationState, InvitationToken, ProjectId, Token, + User, UserId, UserRole, UsernameString, }; use crate::db::DbExecutor; @@ -139,15 +139,15 @@ impl Handler for DbExecutor { } pub struct AcceptInvitation { - pub id: InvitationId, + pub invitation_token: InvitationToken, } impl Message for AcceptInvitation { - type Result = Result; + type Result = Result; } impl Handler for DbExecutor { - type Result = Result; + type Result = Result; fn handle(&mut self, msg: AcceptInvitation, _ctx: &mut Self::Context) -> Self::Result { use crate::schema::invitations::dsl::*; @@ -157,7 +157,7 @@ impl Handler for DbExecutor { .get() .map_err(|_| ServiceErrors::DatabaseConnectionLost)?; - let query = invitations.find(msg.id); + let query = invitations.filter(bind_token.eq(msg.invitation_token)); debug!("{}", diesel::debug_query::(&query).to_string()); let invitation: Invitation = query .first(conn) @@ -206,6 +206,15 @@ impl Handler for DbExecutor { .map_err(|e| ServiceErrors::DatabaseQueryFailed(format!("{}", e)))?; }; - Ok(user) + let token = { + use crate::schema::tokens::dsl::*; + let query = tokens.filter(user_id.eq(user.id)); + debug!("{}", diesel::debug_query::(&query)); + query + .first(conn) + .map_err(|e| ServiceErrors::DatabaseQueryFailed(format!("{}", e)))? + }; + + Ok(token) } } diff --git a/jirs-server/src/db/user_projects.rs b/jirs-server/src/db/user_projects.rs index 0aed2b77..21d86a3d 100644 --- a/jirs-server/src/db/user_projects.rs +++ b/jirs-server/src/db/user_projects.rs @@ -2,7 +2,7 @@ use actix::{Handler, Message}; use diesel::pg::Pg; use diesel::prelude::*; -use jirs_data::{UserId, UserProject, UserProjectId}; +use jirs_data::{ProjectId, UserId, UserProject, UserProjectId, UserRole}; use crate::db::DbExecutor; use crate::errors::ServiceErrors; @@ -109,3 +109,61 @@ impl Handler for DbExecutor { Ok(user_project) } } + +pub struct RemoveInvitedUser { + pub invited_id: UserId, + pub inviter_id: UserId, + pub project_id: ProjectId, +} + +impl Message for RemoveInvitedUser { + type Result = Result<(), ServiceErrors>; +} + +impl Handler for DbExecutor { + type Result = Result<(), ServiceErrors>; + + fn handle(&mut self, msg: RemoveInvitedUser, _ctx: &mut Self::Context) -> Self::Result { + use crate::schema::user_projects::dsl::*; + + let conn = &self + .pool + .get() + .map_err(|_| ServiceErrors::DatabaseConnectionLost)?; + + if msg.invited_id == msg.inviter_id { + return Err(ServiceErrors::Unauthorized); + } + + { + let owner = UserRole::Owner; + let query = user_projects.filter( + user_id + .eq(msg.inviter_id) + .and(project_id.eq(msg.project_id)) + .and(role.eq(owner)), + ); + debug!("{}", diesel::debug_query::(&query)); + query + .first::(conn) + .map_err(|_e| ServiceErrors::Unauthorized)?; + } + + { + let query = diesel::delete(user_projects).filter( + user_id + .eq(msg.invited_id) + .and(project_id.eq(msg.project_id)), + ); + debug!("{}", diesel::debug_query::(&query)); + query.execute(conn).map_err(|_e| { + ServiceErrors::RecordNotFound(format!( + "user project user with id {} for project {}", + msg.invited_id, msg.project_id + )) + })?; + } + + Ok(()) + } +} diff --git a/jirs-server/src/ws/invitations.rs b/jirs-server/src/ws/invitations.rs index 0a2a6f2a..c8ca55ef 100644 --- a/jirs-server/src/ws/invitations.rs +++ b/jirs-server/src/ws/invitations.rs @@ -1,6 +1,6 @@ use futures::executor::block_on; -use jirs_data::{EmailString, InvitationId, UserRole, UsernameString, WsMsg}; +use jirs_data::{EmailString, InvitationId, InvitationToken, UserRole, UsernameString, WsMsg}; use crate::db::invitations; use crate::ws::{WebSocketActor, WsHandler, WsResult}; @@ -131,22 +131,23 @@ impl WsHandler for WebSocketActor { } pub struct AcceptInvitation { - pub id: InvitationId, + pub invitation_token: InvitationToken, } impl WsHandler for WebSocketActor { fn handle_msg(&mut self, msg: AcceptInvitation, _ctx: &mut Self::Context) -> WsResult { - self.require_user()?; - let AcceptInvitation { id } = msg; - let res = match block_on(self.db.send(invitations::AcceptInvitation { id })) { - Ok(Ok(_)) => Some(WsMsg::InvitationAcceptSuccess(id)), + let AcceptInvitation { invitation_token } = msg; + let res = match block_on(self.db.send(invitations::AcceptInvitation { + invitation_token: invitation_token.clone(), + })) { + Ok(Ok(token)) => Some(WsMsg::InvitationAcceptSuccess(token.access_token)), Ok(Err(e)) => { error!("{:?}", e); - return Ok(None); + Some(WsMsg::InvitationAcceptFailure(invitation_token)) } Err(e) => { error!("{}", e); - return Ok(None); + Some(WsMsg::InvitationAcceptFailure(invitation_token)) } }; Ok(res) diff --git a/jirs-server/src/ws/mod.rs b/jirs-server/src/ws/mod.rs index b4dc97bd..c5c47807 100644 --- a/jirs-server/src/ws/mod.rs +++ b/jirs-server/src/ws/mod.rs @@ -150,6 +150,9 @@ impl WebSocketActor { // users WsMsg::ProjectUsersRequest => self.handle_msg(LoadProjectUsers, ctx)?, + WsMsg::InvitedUserRemoveRequest(user_id) => { + self.handle_msg(RemoveInvitedUser { user_id }, ctx)? + } // comments WsMsg::IssueCommentsRequest(issue_id) => { @@ -166,7 +169,9 @@ impl WebSocketActor { self.handle_msg(CreateInvitation { name, email, role }, ctx)? } WsMsg::InvitationListRequest => self.handle_msg(ListInvitation, ctx)?, - WsMsg::InvitationAcceptRequest(id) => self.handle_msg(AcceptInvitation { id }, ctx)?, + WsMsg::InvitationAcceptRequest(invitation_token) => { + self.handle_msg(AcceptInvitation { invitation_token }, ctx)? + } WsMsg::InvitationRevokeRequest(id) => self.handle_msg(RevokeInvitation { id }, ctx)?, WsMsg::InvitedUsersRequest => self.handle_msg(LoadInvitedUsers, ctx)?, diff --git a/jirs-server/src/ws/users.rs b/jirs-server/src/ws/users.rs index cc8763fa..ee87d3be 100644 --- a/jirs-server/src/ws/users.rs +++ b/jirs-server/src/ws/users.rs @@ -1,7 +1,8 @@ use futures::executor::block_on; -use jirs_data::WsMsg; +use jirs_data::{UserId, UserProject, WsMsg}; +use crate::db; use crate::db::users::Register as DbRegister; use crate::ws::auth::Authenticate; use crate::ws::{WebSocketActor, WsHandler, WsResult}; @@ -101,3 +102,35 @@ impl WsHandler for WebSocketActor { Ok(Some(WsMsg::ProfileUpdated)) } } + +pub struct RemoveInvitedUser { + pub user_id: UserId, +} + +impl WsHandler for WebSocketActor { + fn handle_msg(&mut self, msg: RemoveInvitedUser, _ctx: &mut Self::Context) -> WsResult { + let RemoveInvitedUser { + user_id: invited_id, + } = msg; + let UserProject { + user_id: inviter_id, + project_id, + .. + } = self.require_user_project()?.clone(); + match block_on(self.db.send(db::user_projects::RemoveInvitedUser { + invited_id, + inviter_id, + project_id, + })) { + Ok(Ok(_users)) => Ok(Some(WsMsg::InvitedUserRemoveSuccess(invited_id))), + Ok(Err(e)) => { + error!("{:?}", e); + return Ok(None); + } + Err(e) => { + error!("{}", e); + return Ok(None); + } + } + } +}