Improve accept invitation

This commit is contained in:
Adrian Wozniak 2020-05-22 17:35:32 +02:00
parent e618a4f23c
commit 3cb74084d9
22 changed files with 375 additions and 59 deletions

94
.dockerignore Normal file
View File

@ -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/

3
.gitignore vendored
View File

@ -7,4 +7,7 @@ db.toml
db.test.toml
pkg
jirs-client/pkg
jirs-client/tmp
jirs-client/build
tmp
jirs-server/target

View File

@ -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: .

View File

@ -5,3 +5,4 @@ dist
tmp
dev/styles.css
build
target

View File

@ -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;
}

View File

@ -30,3 +30,4 @@
@import "./css/login.css";
@import "./css/register.css";
@import "./css/users.css";
@import "./css/invite.css";

View File

@ -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

View File

@ -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)]

View File

@ -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<Msg>) {
if let Msg::ChangePage(Page::Project) = msg {
build_page_content(model);
return;
}
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
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<Msg> {
@ -37,21 +64,46 @@ pub fn view(model: &Model) -> Node<Msg> {
_ => 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<InvitePage>) -> Node<Msg> {
let submit = StyledButton::build()
.text("Accept")
.primary()
.build()
.into_node();
StyledField::build().input(submit).build().into_node()
}
fn token_field(page: &Box<InvitePage>) -> Node<Msg> {
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()
}

View File

@ -53,7 +53,7 @@ pub enum Msg {
InviteRequest,
InviteRevokeRequest(InvitationId),
InviteApproveRequest(InvitationId),
InvitedUserRemove(EmailString),
InvitedUserRemove(UserId),
// sign up
SignUpRequest,

View File

@ -62,6 +62,11 @@ pub fn update(msg: &Msg, model: &mut model::Model, orders: &mut impl Orders<Msg>
model.modals.push(ModalType::DebugModal);
}
#[cfg(debug_assertions)]
Msg::GlobalKeyDown { key, .. } if key.eq(">") => {
log!(model);
}
_ => (),
}
add_issue::update(msg, model, orders);

View File

@ -262,6 +262,7 @@ pub struct ProjectPage {
pub struct InvitePage {
pub token: String,
pub token_touched: bool,
pub error: Option<String>,
}
#[derive(Debug)]

View File

@ -77,10 +77,12 @@ pub fn inner_layout(
]
}
pub fn outer_layout(_model: &Model, page_name: &str, children: Vec<Node<Msg>>) -> Node<Msg> {
pub fn outer_layout(model: &Model, page_name: &str, children: Vec<Node<Msg>>) -> Node<Msg> {
let modal = crate::modal::view(model);
article![
class!["outer-layout", "outerPage"],
id![page_name],
modal,
children
]
}

View File

@ -45,11 +45,11 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
}
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<Msg>) {
orders,
);
}
Msg::InvitedUserRemove(email) => {
Msg::InvitedUserRemove(user_id) => {
send_ws_msg(
WsMsg::InvitedUserRemoveRequest(email),
WsMsg::InvitedUserRemoveRequest(user_id),
model.ws.as_ref(),
orders,
);

View File

@ -108,10 +108,12 @@ pub fn view(model: &Model) -> Node<Msg> {
.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

View File

@ -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,

15
jirs-server/Dockerfile Normal file
View File

@ -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

View File

@ -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<RevokeInvitation> for DbExecutor {
}
pub struct AcceptInvitation {
pub id: InvitationId,
pub invitation_token: InvitationToken,
}
impl Message for AcceptInvitation {
type Result = Result<User, ServiceErrors>;
type Result = Result<Token, ServiceErrors>;
}
impl Handler<AcceptInvitation> for DbExecutor {
type Result = Result<User, ServiceErrors>;
type Result = Result<Token, ServiceErrors>;
fn handle(&mut self, msg: AcceptInvitation, _ctx: &mut Self::Context) -> Self::Result {
use crate::schema::invitations::dsl::*;
@ -157,7 +157,7 @@ impl Handler<AcceptInvitation> 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::<Pg, _>(&query).to_string());
let invitation: Invitation = query
.first(conn)
@ -206,6 +206,15 @@ impl Handler<AcceptInvitation> 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::<Pg, _>(&query));
query
.first(conn)
.map_err(|e| ServiceErrors::DatabaseQueryFailed(format!("{}", e)))?
};
Ok(token)
}
}

View File

@ -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<ChangeCurrentUserProject> 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<RemoveInvitedUser> 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::<Pg, _>(&query));
query
.first::<UserProject>(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::<Pg, _>(&query));
query.execute(conn).map_err(|_e| {
ServiceErrors::RecordNotFound(format!(
"user project user with id {} for project {}",
msg.invited_id, msg.project_id
))
})?;
}
Ok(())
}
}

View File

@ -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<RevokeInvitation> for WebSocketActor {
}
pub struct AcceptInvitation {
pub id: InvitationId,
pub invitation_token: InvitationToken,
}
impl WsHandler<AcceptInvitation> 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)

View File

@ -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)?,

View File

@ -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<ProfileUpdate> for WebSocketActor {
Ok(Some(WsMsg::ProfileUpdated))
}
}
pub struct RemoveInvitedUser {
pub user_id: UserId,
}
impl WsHandler<RemoveInvitedUser> 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);
}
}
}
}