Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow hotlink to organization-specific sign-up form #2898

Merged
merged 8 commits into from
Jul 19, 2018
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.md).
Example: `<taskBoundingBox topLeftX="0" topLeftY="0" topLeftZ="0" width="512" height="512" depth="512" />`
- Added the possibility to kick a user out of the organization team. [#2801](https://github.com/scalableminds/webknossos/pull/2801)
- Added a mandatory waiting interval of 10 seconds when getting a task with a new task type. The modal containing the task description cannot be closed earlier. These ten seconds should be used to fully understand the new task type. [#2793](https://github.com/scalableminds/webknossos/pull/2793)
- Added possibility to share a special link to invite users to join your organization. Following that link, the sign-up form will automatically register the user for the correct organization. [#2898](https://github.com/scalableminds/webknossos/pull/2898)
- Added more debugging related information in case of unexpected errors. The additional information can be used when reporting the error. [#2766](https://github.com/scalableminds/webknossos/pull/2766)
- Added permission for team managers to create explorational tracings on datasets without allowed teams. [#2758](https://github.com/scalableminds/webknossos/pull/2758)
- Added higher-resolution images for dataset gallery thumbnails. [#2745](https://github.com/scalableminds/webknossos/pull/2745)
Expand Down
1 change: 1 addition & 0 deletions app/assets/javascripts/admin/api_flow_types.js
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ export type APIOrganizationType = {
+id: string,
+name: string,
+additionalInformation: string,
+displayName: string,
};

export type APIBuildInfoType = {
Expand Down
97 changes: 64 additions & 33 deletions app/assets/javascripts/admin/auth/registration_form.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,28 @@ import { Link } from "react-router-dom";
import { Form, Input, Button, Row, Col, Icon, Select, Checkbox } from "antd";
import messages from "messages";
import Request from "libs/request";
import { loginUser, getOrganizationNames } from "admin/admin_rest_api";
import { loginUser, getOrganizations } from "admin/admin_rest_api";
import type { APIOrganizationType } from "admin/api_flow_types";
import Store from "oxalis/throttled_store";
import { setActiveUserAction } from "oxalis/model/actions/user_actions";

const FormItem = Form.Item;
const { Option } = Select;

type Props = {
type Props = {|
form: Object,
onRegistered: () => void,
confirmLabel?: string,
organizationName?: string,
createOrganization?: boolean,
organizationName?: ?string,
hidePrivacyStatement?: boolean,
tryAutoLogin?: boolean,
};
onOrganizationNameNotFound?: () => void,
|};

type State = {
confirmDirty: boolean,
organizations: Array<string>,
organizations: Array<APIOrganizationType>,
};

class RegistrationView extends React.PureComponent<Props, State> {
Expand All @@ -36,9 +39,24 @@ class RegistrationView extends React.PureComponent<Props, State> {
}

async fetchData() {
if (this.props.organizationName == null) {
const organizations = await getOrganizationNames();
this.setState({ organizations });
if (this.props.createOrganization) {
// Since we are creating a new organization, we don't need to fetch existing organizations
return;
}

this.setState({ organizations: await getOrganizations() });
this.validateOrganizationName();
}

validateOrganizationName() {
if (!this.props.organizationName) {
return;
}
if (
this.state.organizations.find(org => org.name === this.props.organizationName) == null &&
this.props.onOrganizationNameNotFound
) {
this.props.onOrganizationNameNotFound();
}
}

Expand All @@ -50,7 +68,7 @@ class RegistrationView extends React.PureComponent<Props, State> {
return;
}
await Request.sendJSONReceiveJSON(
this.props.organizationName != null
this.props.createOrganization != null
? "/api/auth/createOrganizationWithAdmin"
: "/api/auth/register",
{ data: formValues },
Expand Down Expand Up @@ -88,40 +106,53 @@ class RegistrationView extends React.PureComponent<Props, State> {
callback();
};

render() {
getOrganizationFormField() {
const { getFieldDecorator } = this.props.form;

const organizationComponents =
this.props.organizationName == null ? (
<FormItem hasFeedback>
{getFieldDecorator("organization", {
rules: [
{
required: true,
message: messages["auth.registration_org_input"],
},
],
})(
<Select placeholder="Organization">
{this.state.organizations.map(organization => (
<Option value={organization} key={organization}>
{organization}
</Option>
))}
</Select>,
)}
</FormItem>
) : (
if (this.props.createOrganization || this.props.organizationName) {
if (!this.props.organizationName) {
throw new Error("When createOrganization is set, organizationName must be passed as well.");
}
// The user is either
// - creating a complete new organization or
// - the organization is specified via the URL
// Thus, the organization field is hidden.
return (
<FormItem style={{ display: "none" }}>
{getFieldDecorator("organization", { initialValue: this.props.organizationName })(
<Input type="text" />,
)}
</FormItem>
);
}

return (
<FormItem hasFeedback>
{getFieldDecorator("organization", {
rules: [
{
required: true,
message: messages["auth.registration_org_input"],
},
],
})(
<Select placeholder="Organization">
{this.state.organizations.map(organization => (
<Option value={organization.name} key={organization.name}>
{organization.displayName}
</Option>
))}
</Select>,
)}
</FormItem>
);
}

render() {
const { getFieldDecorator } = this.props.form;

return (
<Form onSubmit={this.handleSubmit}>
{organizationComponents}
{this.getOrganizationFormField()}
<Row gutter={8}>
<Col span={12}>
<FormItem hasFeedback>
Expand Down
37 changes: 32 additions & 5 deletions app/assets/javascripts/admin/auth/registration_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,51 @@ import RegistrationForm from "./registration_form";

type Props = {
history: RouterHistory,
organizationName: ?string,
};

class RegistrationView extends React.PureComponent<Props> {
getGreetingCard() {
const { organizationName } = this.props;
if (organizationName) {
return (
<Card style={{ marginBottom: 24 }}>
You were invited to join the organization &ldquo;{organizationName}&rdquo;!<br /> In case
you do not know this organization, contact{" "}
<a href="mailto:[email protected]">[email protected]</a> to get more
information about how to get to use webKnossos.
</Card>
);
}

return (
<Card style={{ marginBottom: 24 }}>
Not a member of the listed organizations?<br /> Contact{" "}
<a href="mailto:[email protected]">[email protected]</a> to get more information
about how to get to use webKnossos.
</Card>
);
}

render() {
return (
<Row type="flex" justify="center" style={{ padding: 50 }} align="middle">
<Col span={8}>
<h3>Registration</h3>
<Card style={{ marginBottom: 24 }}>
Not a member of the listed organizations?<br /> Contact{" "}
<a href="mailto:[email protected]">[email protected]</a> to get more
information about how to get to use webKnossos.
</Card>
{this.getGreetingCard()}
<RegistrationForm
// The key is used to enforce a remount in case the organizationName changes.
// That way, we ensure that the organization field is cleared.
key={this.props.organizationName || "default registration form key"}
organizationName={this.props.organizationName}
onRegistered={() => {
Toast.success(messages["auth.account_created"]);
this.props.history.push("/auth/login");
}}
onOrganizationNameNotFound={() => {
Toast.error(messages["auth.invalid_organization_name"]);
this.props.history.push("/auth/register");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my understanding this error can only be triggered if the user already is on the register page. So I'd say there's no need to push this to the history.

Copy link
Member Author

@philippotto philippotto Jul 16, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, I'm not sure I get what you mean. If this error happens, the user is on /auth/register?organizationName=someInvalidOrgName. Then, he should be redirected to /auth/register, so that the dropdown appears again. Or do you mean that something like history.replace should be used?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, you're right. I didn't get that this line was being used to "get rid" of the organizationName url parameter :)

}}
/>
<Link to="/auth/login">Already have an account? Login instead.</Link>
</Col>
Expand Down
70 changes: 65 additions & 5 deletions app/assets/javascripts/admin/onboarding.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
// @flow

import * as React from "react";
import { Form, Modal, Input, Button, Row, Col, Steps, Icon, Card } from "antd";
import { Form, Popover, Modal, Input, Button, Row, Col, Steps, Icon, Card } from "antd";
import Toast from "libs/toast";
import Clipboard from "clipboard-js";
import { connect } from "react-redux";
import type { OxalisState } from "oxalis/store";
import type { APIUserType } from "admin/api_flow_types";
import { location } from "libs/window";

import RegistrationForm from "admin/auth/registration_form";
import DatasetUploadView from "admin/dataset/dataset_upload_view";
Expand All @@ -10,6 +16,10 @@ import DatasetImportView from "dashboard/dataset/dataset_import_view";
const Step = Steps.Step;
const FormItem = Form.Item;

type StateProps = {
activeUser: ?APIUserType,
};

type State = {
currentStep: number,
organizationName: string,
Expand Down Expand Up @@ -53,6 +63,44 @@ function FeatureCard({ icon, header, children }) {
);
}

export class InviteUsersPopover extends React.Component<{
organizationName: string,
children: React.Node,
}> {
getRegistrationHotLink(): string {
return `${location.origin}/auth/register?organizationName=${encodeURI(
this.props.organizationName,
)}`;
}

copyRegistrationCopyLink = async () => {
await Clipboard.copy(this.getRegistrationHotLink());
Toast.success("Registration link copied to clipboard.");
};

getContent() {
return (
<React.Fragment>
<div style={{ marginBottom: 8 }}>
Share the following link to let users join your organization:
</div>
<Input.Group compact>
<Input style={{ width: "85%" }} value={this.getRegistrationHotLink()} readOnly />
<Button style={{ width: "15%" }} onClick={this.copyRegistrationCopyLink} icon="copy" />
</Input.Group>
</React.Fragment>
);
}

render() {
return (
<Popover trigger="click" title="Invite Users" content={this.getContent()}>
{this.props.children}
</Popover>
);
}
}

const OrganizationForm = Form.create()(({ form, onComplete }) => {
const hasErrors = fieldsError => Object.keys(fieldsError).some(field => fieldsError[field]);
const handleSubmit = e => {
Expand Down Expand Up @@ -108,7 +156,7 @@ const OrganizationForm = Form.create()(({ form, onComplete }) => {
);
});

class OnboardingView extends React.PureComponent<{}, State> {
class OnboardingView extends React.PureComponent<StateProps, State> {
constructor() {
super();
this.state = {
Expand Down Expand Up @@ -165,6 +213,7 @@ class OnboardingView extends React.PureComponent<{}, State> {
>
<RegistrationForm
hidePrivacyStatement
createOrganization
organizationName={this.state.organizationName}
onRegistered={this.advanceStep}
confirmLabel="Create account"
Expand Down Expand Up @@ -247,8 +296,15 @@ class OnboardingView extends React.PureComponent<{}, State> {
the formats and upload processes webKnossos supports.
</FeatureCard>
<FeatureCard header="User & Team Management" icon={<Icon type="team" />}>
Invite <a href="/users">users</a> and assign them to <a href="/teams">teams</a>. Teams
can be used to define dataset permissions and task assignments.
<InviteUsersPopover
organizationName={
this.props.activeUser != null ? this.props.activeUser.organization : ""
}
>
<a href="#">Invite users</a>{" "}
</InviteUsersPopover>
and assign them to <a href="/teams">teams</a>. Teams can be used to define dataset
permissions and task assignments.
</FeatureCard>
<FeatureCard header="Project Management" icon={<Icon type="paper-clip" />}>
Create <a href="/tasks">tasks</a> and <a href="/projects">projects</a> to efficiently
Expand Down Expand Up @@ -318,4 +374,8 @@ class OnboardingView extends React.PureComponent<{}, State> {
}
}

export default OnboardingView;
const mapStateToProps = (state: OxalisState): StateProps => ({
activeUser: state.activeUser,
});

export default connect(mapStateToProps)(OnboardingView);
6 changes: 6 additions & 0 deletions app/assets/javascripts/admin/user/user_list_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { RouterHistory } from "react-router-dom";
import type { OxalisState } from "oxalis/store";
import EditableTextLabel from "oxalis/view/components/editable_text_label";
import Toast from "libs/toast";
import { InviteUsersPopover } from "admin/onboarding";
import Store from "../../oxalis/store";
import { logoutUserAction } from "../../oxalis/model/actions/user_actions";

Expand Down Expand Up @@ -225,6 +226,11 @@ class UserListView extends React.PureComponent<Props, State> {
Grant Admin Rights
</Button>
) : null}
<InviteUsersPopover organizationName={this.props.activeUser.organization}>
<Button icon="user-add" style={marginRight}>
Invite Users
</Button>
</InviteUsersPopover>
{activationFilterWarning}
<Search
style={{ width: 200, float: "right" }}
Expand Down
11 changes: 8 additions & 3 deletions app/assets/javascripts/libs/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import pako from "pako";
import type { APIUserType } from "admin/api_flow_types";

type Comparator<T> = (T, T) => -1 | 0 | 1;
type UrlParamsType = { [key: string]: string | boolean };

function swap(arr, a, b) {
let tmp;
Expand Down Expand Up @@ -226,12 +227,16 @@ const Utils = {
return user.isAdmin || this.isUserTeamManager(user);
},

getUrlParamsObject(): { [key: string]: string | boolean } {
getUrlParamsObject(): UrlParamsType {
return this.getUrlParamsObjectFromString(location.search);
},

getUrlParamsObjectFromString(str: string): UrlParamsType {
// Parse the URL parameters as objects and return it or just a single param
return location.search
return str
.substring(1)
.split("&")
.reduce((result, value): void => {
.reduce((result: UrlParamsType, value: string): UrlParamsType => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

const parts = value.split("=");
if (parts[0]) {
const key = decodeURIComponent(parts[0]);
Expand Down
2 changes: 2 additions & 0 deletions app/assets/javascripts/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ In order to restore the current window, a reload is necessary.`,
"Your account has been created. An administrator is going to unlock you soon.",
"auth.automatic_user_activation": "User was activated automatically",
"auth.error_no_user": "No active user is logged in.",
"auth.invalid_organization_name":
"The link is not valid, since the specified organization does not exist. You are being redirected to the general registration form.",
"request.max_item_count_alert":
"Your request returned more than 1000 results. More results might be available on the server but were omitted for technical reasons.",
"timetracking.date_range_too_long": "Please specify a date range of 31 days or less.",
Expand Down
Loading