commit 504b70904c5b37d8a1306e9cfdfd0280889e046e Author: Tim Brandin Date: Mon Mar 28 22:34:50 2016 +0200 Inital commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4c6fc8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.swp +*~ +*.iml +.*.haste_cache.* +.DS_Store +.idea +npm-debug.log +node_modules +dist diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9fdab50 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# ChangeLog + +### v1.0.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e458c61 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Studio Interact Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f9a3b7 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# React Accounts UI for Meteor 1.3 + +Current version 1.0.0 + +## Features + +1. **Easy to use**, mixing the ideas of useraccounts configuration and accounts-ui that everyone already knows. +2. **Server Side Rendering** are supported, trough FlowRouter (SSR). +3. **Components** are everywhere, and extensible by replacing them on Accounts.ui. +4. **Basic routing** included. +5. **Unstyled** is the default, no CSS included. + +### Styled version + +* Check back here later for styled version i.e. bootstrap, semantic-ui. + +We're hoping package developers write extensions for these by importing like this: + +```javascript +import { Accounts } from 'meteor/studiointeract:react-accounts-ui'; + +/** + * Form.propTypes = { + * fields: React.PropTypes.object.isRequired, + * buttons: React.PropTypes.object.isRequired, + * error: React.PropTypes.string, + * ready: React.PropTypes.bool + * }; + */ +class Form extends Accounts.ui.Form { + render() { + const { fields, buttons, error, message, ready = true} = this.props; + return ( +
evt.preventDefault() } className="accounts-ui"> + + + + + ); + } +} +// Overwrite provided form. +Accounts.ui.Form = Form; +exports default Accounts; + +``` + +### Available components + +* Accounts.ui.LoginForm + * Accounts.ui.Form + * Accounts.ui.Fields + * Accounts.ui.Field + * Accounts.ui.Buttons + * Accounts.ui.Button + * Accounts.ui.FormMessage + +## Installation + +### Meteor 1.3 + +`meteor add studiointeract:react-accounts-ui` + +> Configuration instructions coming shortly! In the meantime check the example below, and for full details about configuration options check in `./lib/accounts_ui.js` + +### Meteor 1.2 + +> Not supported. + +## Example setup using FlowRouter (Meteor 1.3) + +```javascript + +import { FlowRouter } from 'meteor/kadira:flow-router-ssr'; +import { Accounts } from 'meteor/studiointeract:react-accounts'; +import React from 'react'; + +Accounts.ui.config({ + passwordSignupFields: 'USERNAME_AND_OPTIONAL_EMAIL', + onSubmitHook(error, state) { + console.log('onSubmitHook') + }, + preSignUpHook(password, info) { + console.log('preSignUpHook') + }, + postSignUpHook(userId, info) { + console.log('postSignUpHook') + }, + onSignedInHook: () => redirect('/general'), + onSignedOutHook: () => redirect('/') +}); + +FlowRouter.route("/login", { + action(params) { + mount(MainLayout, { + content: + }); + } +}); + +``` diff --git a/imports/accounts_ui.js b/imports/accounts_ui.js new file mode 100644 index 0000000..56e72e9 --- /dev/null +++ b/imports/accounts_ui.js @@ -0,0 +1,178 @@ +import {Accounts} from 'meteor/accounts-base'; +import { redirect } from './helpers.js'; + +/** + * @summary Accounts UI + * @namespace + * @memberOf Accounts + */ +Accounts.ui = {}; + +Accounts.ui._options = { + requestPermissions: {}, + requestOfflineToken: {}, + forceApprovalPrompt: {}, + loginPath: '/login', + onSubmitHook: [], + preSignUpHook: [], + postSignUpHook: [], + loginHook: () => redirect(`${Accounts.ui._options.loginPath}`), + signUpHook: () => redirect(`${Accounts.ui._options.loginPath}`), + resetPasswordHook: () => redirect(`${Accounts.ui._options.loginPath}`), + changePasswordHook: () => redirect(`${Accounts.ui._options.loginPath}`), + onEnrollAccountHook: () => redirect(`${Accounts.ui._options.loginPath}`), + onResetPasswordHook: () => redirect(`${Accounts.ui._options.loginPath}`), + onVerifyEmailHook: () => redirect(`${Accounts.ui._options.loginPath}`), + onSignedInHook: () => redirect('/'), + onSignedOutHook: () => redirect('/') +}; + +/** + * @summary Configure the behavior of [`{{> loginButtons}}`](#accountsui). + * @locus Client + * @param {Object} options + * @param {Object} options.requestPermissions Which [permissions](#requestpermissions) to request from the user for each external service. + * @param {Object} options.requestOfflineToken To ask the user for permission to act on their behalf when offline, map the relevant external service to `true`. Currently only supported with Google. See [Meteor.loginWithExternalService](#meteor_loginwithexternalservice) for more details. + * @param {Object} options.forceApprovalPrompt If true, forces the user to approve the app's permissions, even if previously approved. Currently only supported with Google. + * @param {String} options.passwordSignupFields Which fields to display in the user creation form. One of '`USERNAME_AND_EMAIL`', '`USERNAME_AND_OPTIONAL_EMAIL`', '`USERNAME_ONLY`', or '`EMAIL_ONLY`' (default). + */ +Accounts.ui.config = function(options) { + // validate options keys + const VALID_KEYS = [ + 'passwordSignupFields', + 'requestPermissions', + 'requestOfflineToken', + 'forceApprovalPrompt', + 'loginPath', + 'onSubmitHook', + 'preSignUpHook', + 'postSignUpHook', + 'loginHook', + 'signUpHook', + 'resetPasswordHook', + 'changePasswordHook', + 'onEnrollAccountHook', + 'onResetPasswordHook', + 'onVerifyEmailHook', + 'onSignedInHook', + 'onSignedOutHook' + ]; + + _.each(_.keys(options), function (key) { + if (!_.contains(VALID_KEYS, key)) + throw new Error("Accounts.ui.config: Invalid key: " + key); + }); + + // deal with `passwordSignupFields` + if (options.passwordSignupFields) { + if (_.contains([ + "USERNAME_AND_EMAIL", + "USERNAME_AND_OPTIONAL_EMAIL", + "USERNAME_ONLY", + "EMAIL_ONLY" + ], options.passwordSignupFields)) { + if (Accounts.ui._options.passwordSignupFields) + throw new Error("Accounts.ui.config: Can't set `passwordSignupFields` more than once"); + else + Accounts.ui._options.passwordSignupFields = options.passwordSignupFields; + } + else { + throw new Error("Accounts.ui.config: Invalid option for `passwordSignupFields`: " + options.passwordSignupFields); + } + } + + // deal with `requestPermissions` + if (options.requestPermissions) { + _.each(options.requestPermissions, function (scope, service) { + if (Accounts.ui._options.requestPermissions[service]) { + throw new Error("Accounts.ui.config: Can't set `requestPermissions` more than once for " + service); + } + else if (!(scope instanceof Array)) { + throw new Error("Accounts.ui.config: Value for `requestPermissions` must be an array"); + } + else { + Accounts.ui._options.requestPermissions[service] = scope; + } + }); + } + + // deal with `requestOfflineToken` + if (options.requestOfflineToken) { + _.each(options.requestOfflineToken, function (value, service) { + if (service !== 'google') + throw new Error("Accounts.ui.config: `requestOfflineToken` only supported for Google login at the moment."); + + if (Accounts.ui._options.requestOfflineToken[service]) { + throw new Error("Accounts.ui.config: Can't set `requestOfflineToken` more than once for " + service); + } + else { + Accounts.ui._options.requestOfflineToken[service] = value; + } + }); + } + + // deal with `forceApprovalPrompt` + if (options.forceApprovalPrompt) { + _.each(options.forceApprovalPrompt, function (value, service) { + if (service !== 'google') + throw new Error("Accounts.ui.config: `forceApprovalPrompt` only supported for Google login at the moment."); + + if (Accounts.ui._options.forceApprovalPrompt[service]) { + throw new Error("Accounts.ui.config: Can't set `forceApprovalPrompt` more than once for " + service); + } + else { + Accounts.ui._options.forceApprovalPrompt[service] = value; + } + }); + } + + // deal with the hooks. + for (let hook of ['onSubmitHook', 'preSignUpHook', 'postSignUpHook']) { + if (options[hook]) { + if (typeof options[hook] != 'function') { + throw new Error(`Accounts.ui.config: "${hook}" not a function`); + } + else { + Accounts.ui._options[hook].push(options[hook]); + } + } + } + + // deal with `forceApprovalPrompt`. + if (options.loginPath) { + if (typeof options.loginPath != 'string') { + Accounts.ui._options.loginPath = options.loginPath; + } + else { + throw new Error(`Accounts.ui.config: "loginPath" not an absolute or relative path`); + } + } + + // deal with redirect hooks. + for (let hook of [ + 'loginHook', + 'signUpHook', + 'resetPasswordHook', + 'changePasswordHook', + 'onEnrollAccountHook', + 'onResetPasswordHook', + 'onVerifyEmailHook', + 'onSignedInHook', + 'onSignedOutHook']) { + if (options[hook]) { + if (typeof options[hook] == 'function') { + Accounts.ui._options[hook] = options[hook]; + } + else if (typeof options[hook] == 'string') { + Accounts.ui._options[hook] = () => { + location.href = options[hook]; + }; + } + else { + throw new Error(`Accounts.ui.config: "${hook}" not a function or an absolute or relative path`); + } + } + } +}; + +export default Accounts; diff --git a/imports/helpers.js b/imports/helpers.js new file mode 100644 index 0000000..d16f790 --- /dev/null +++ b/imports/helpers.js @@ -0,0 +1,66 @@ +export function t9n(str) { + return str; +}; + +export function getLoginServices() { + // First look for OAuth services. + const services = Package['accounts-oauth'] ? Accounts.oauth.serviceNames() : []; + + // Be equally kind to all login services. This also preserves + // backwards-compatibility. + services.sort(); + + return _.map(services, function(name){ + return {name: name}; + }); +}; +// Export getLoginServices using old style globals for accounts-base which +// requires it. +this.getLoginServices = getLoginServices; + +export function loginResultCallback(redirect) { + if (Meteor.isClient){ + if (typeof redirect === 'string'){ + window.location.href = redirect; + } + + if (typeof redirect === 'function'){ + redirect(); + } + } +}; + +export function passwordSignupFields() { + return Accounts.ui._options.passwordSignupFields || "EMAIL_ONLY"; +}; + +export function validatePassword(password){ + if (password.length >= 6) { + return true; + } else { + return false; + } +}; + +// Helper to dynamically inject react components. +Global = this; +export function Inject(component, ...args) { + return Global[component] ? React.createElement.apply(React.createElement, [Global[component], ...args]) : ''; +} + +export function redirect(path) { + if (Meteor.isClient) { + if (window.history) { + window.history.replaceState( {} , 'redirect', path ); + } + else { + window.location.href = href; + } + } +} + +// capitalize = function(str){ +// str = str == null ? '' : String(str); +// +// return str.charAt(0).toUpperCase() + str.slice(1); +// }; diff --git a/imports/login_session.js b/imports/login_session.js new file mode 100644 index 0000000..6971a81 --- /dev/null +++ b/imports/login_session.js @@ -0,0 +1,96 @@ +import {Accounts} from 'meteor/accounts-base'; +import { + loginResultCallback, + getLoginServices +} from './helpers.js'; + +const VALID_KEYS = [ + 'dropdownVisible', + + // XXX consider replacing these with one key that has an enum for values. + 'inSignupFlow', + 'inForgotPasswordFlow', + 'inChangePasswordFlow', + 'inMessageOnlyFlow', + + 'errorMessage', + 'infoMessage', + + // dialogs with messages (info and error) + 'resetPasswordToken', + 'enrollAccountToken', + 'justVerifiedEmail', + 'justResetPassword', + + 'configureLoginServiceDialogVisible', + 'configureLoginServiceDialogServiceName', + 'configureLoginServiceDialogSaveDisabled', + 'configureOnDesktopVisible' +]; + +const validateKey = function (key) { + if (!_.contains(VALID_KEYS, key)) + throw new Error("Invalid key in loginButtonsSession: " + key); +}; + +const KEY_PREFIX = "Meteor.loginButtons."; + +// XXX This should probably be package scope rather than exported +// (there was even a comment to that effect here from before we had +// namespacing) but accounts-ui-viewer uses it, so leave it as is for +// now +Accounts._loginButtonsSession = { + set: function(key, value) { + validateKey(key); + if (_.contains(['errorMessage', 'infoMessage'], key)) + throw new Error("Don't set errorMessage or infoMessage directly. Instead, use errorMessage() or infoMessage()."); + + this._set(key, value); + }, + + _set: function(key, value) { + Session.set(KEY_PREFIX + key, value); + }, + + get: function(key) { + validateKey(key); + return Session.get(KEY_PREFIX + key); + } +}; + +if (Meteor.isClient){ + // In the login redirect flow, we'll have the result of the login + // attempt at page load time when we're redirected back to the + // application. Register a callback to update the UI (i.e. to close + // the dialog on a successful login or display the error on a failed + // login). + // + Accounts.onPageLoadLogin(function (attemptInfo) { + // Ignore if we have a left over login attempt for a service that is no longer registered. + if (_.contains(_.pluck(getLoginServices(), "name"), attemptInfo.type)) + loginResultCallback(attemptInfo.type, attemptInfo.error); + }); + + let doneCallback; + + Accounts.onResetPasswordLink(function (token, done) { + Accounts._loginButtonsSession.set("resetPasswordToken", token); + doneCallback = done; + }); + + Accounts.onEnrollmentLink(function (token, done) { + Accounts._loginButtonsSession.set("enrollAccountToken", token); + doneCallback = done; + }); + + Accounts.onEmailVerificationLink(function (token, done) { + Accounts.verifyEmail(token, function (error) { + if (! error) { + Accounts._loginButtonsSession.set('justVerifiedEmail', true); + } + + done(); + // XXX show something if there was an error. + }); + }); +} diff --git a/imports/ui/components/Button.jsx b/imports/ui/components/Button.jsx new file mode 100644 index 0000000..f3dacf2 --- /dev/null +++ b/imports/ui/components/Button.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Accounts } from 'meteor/accounts-base'; + +export class Button extends React.Component { + render () { + const { label, type, disabled = false, onClick } = this.props; + return type == 'link' ? ( + { label } + ) : ( + + ); + } +} +Button.propTypes = { + onClick: React.PropTypes.func +}; + +Accounts.ui.Button = Button; diff --git a/imports/ui/components/Buttons.jsx b/imports/ui/components/Buttons.jsx new file mode 100644 index 0000000..7c26ede --- /dev/null +++ b/imports/ui/components/Buttons.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import './Button.jsx'; +import { Accounts } from 'meteor/accounts-base'; + +export const Buttons = ({ buttons = {} }) => ( +
+ {Object.keys(buttons).map((id, i) => + + )} +
+); + +Accounts.ui.Buttons = Buttons; diff --git a/imports/ui/components/Field.jsx b/imports/ui/components/Field.jsx new file mode 100644 index 0000000..0f06acf --- /dev/null +++ b/imports/ui/components/Field.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Accounts } from 'meteor/accounts-base'; + +export class Field extends React.Component { + componentDidMount() { + // Trigger an onChange on inital load, to support browser prefilled values. + const { onChange } = this.props; + let node = ReactDOM.findDOMNode(this); + let value = node.getElementsByTagName('input')[0].value; + // Match the data format of a typical onChange event. + onChange({ target: { value: value } }); + } + + render() { + const { id, hint, label, type = 'text', onChange } = this.props; + return ( +
+ + +
+ ); + } +} +Field.propTypes = { + onChange: React.PropTypes.func +}; + +Accounts.ui.Field = Field; diff --git a/imports/ui/components/Fields.jsx b/imports/ui/components/Fields.jsx new file mode 100644 index 0000000..390c3c7 --- /dev/null +++ b/imports/ui/components/Fields.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Accounts } from 'meteor/accounts-base'; +import './Field.jsx'; + +export const Fields = ({ fields = {} }) => ( +
+ {Object.keys(fields).map((id, i) => + + )} +
+); + +Accounts.ui.Fields = Fields; diff --git a/imports/ui/components/Form.jsx b/imports/ui/components/Form.jsx new file mode 100644 index 0000000..7fb1774 --- /dev/null +++ b/imports/ui/components/Form.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import {Accounts} from 'meteor/accounts-base'; +import './Fields.jsx'; +import './Buttons.jsx'; +import './FormMessage.jsx'; + +export class Form extends React.Component { + render() { + const { fields, buttons, error, message, ready = true} = this.props; + return ( +
evt.preventDefault() } className="accounts-ui"> + + + + + ); + } +} +Form.propTypes = { + fields: React.PropTypes.object.isRequired, + buttons: React.PropTypes.object.isRequired, + error: React.PropTypes.string, + ready: React.PropTypes.bool +}; + +Accounts.ui.Form = Form; diff --git a/imports/ui/components/FormMessage.jsx b/imports/ui/components/FormMessage.jsx new file mode 100644 index 0000000..aeb5117 --- /dev/null +++ b/imports/ui/components/FormMessage.jsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { Accounts } from 'meteor/accounts-base'; + +export const FormMessage = ({ message }) => message ? ( +
{ message }
+) : null; + +Accounts.ui.FormMessage = FormMessage; diff --git a/imports/ui/components/LoginForm.jsx b/imports/ui/components/LoginForm.jsx new file mode 100644 index 0000000..72e5d01 --- /dev/null +++ b/imports/ui/components/LoginForm.jsx @@ -0,0 +1,458 @@ +import React from 'react'; +import Tracker from 'tracker-component'; +import { Accounts } from 'meteor/accounts-base'; +import './Form.jsx'; + +import { + passwordSignupFields, + validatePassword, + loginResultCallback, + getLoginServices, + t9n +} from '../../helpers.js'; + +const STATES = { + SIGN_IN: Symbol('SIGN_IN'), + SIGN_UP: Symbol('SIGN_UP'), + PASSWORD_CHANGE: Symbol('PASSWORD_CHANGE'), + PASSWORD_RESET: Symbol('PASSWORD_RESET') +}; +// Expose available states. +Accounts.ui.STATES = STATES; + +export class LoginForm extends Tracker.Component { + constructor(props) { + super(props); + // Set inital state. + this.state = { + message: '', + waiting: false, + formState: Meteor.user() ? STATES.PASSWORD_CHANGE : STATES.SIGN_IN + }; + + // Listen reactively. + this.autorun(() => { + this.setState({ + user: Meteor.user() + }); + }); + } + + validateUsername( username ){ + if ( username.length >= 3 ) { + return true; + } + else { + this.showMessage( t9n("error.usernameTooShort") ); + + return false; + } + } + + validateEmail( email ){ + if (passwordSignupFields() === "USERNAME_AND_OPTIONAL_EMAIL" && email === '') + return true; + + if (email.indexOf('@') !== -1) { + return true; + } + else { + this.showMessage(t9n("error.emailInvalid")); + + return false; + } + } + + getUsernameOrEmailField(){ + return { + id: 'usernameOrEmail', + hint: t9n('Enter username or email'), + label: t9n('usernameOrEmail'), + onChange: this.handleChange.bind(this, 'usernameOrEmail') + }; + } + + getUsernameField(){ + return { + id: 'username', + hint: t9n('Enter username'), + label: t9n('username'), + onChange: this.handleChange.bind(this, 'username') + }; + } + + getEmailField(){ + return { + id: 'email', + hint: t9n('Enter email'), + label: t9n('email'), + onChange: this.handleChange.bind(this, 'email') + }; + } + + getPasswordField(){ + return { + id: 'password', + hint: t9n('Enter password'), + label: t9n('password'), + type: 'password', + onChange: this.handleChange.bind(this, 'password') + }; + } + + getNewPasswordField(){ + return { + id: 'newPassword', + hint: t9n('Enter newPassword'), + label: t9n('newPassword'), + type: 'password', + onChange: this.handleChange.bind(this, 'password') + }; + } + + handleChange(field, evt) { + let value = evt.target.value; + switch (field) { + case 'password': break; + default: + value = value.trim(); + break; + } + this.setState({ [field]: value }); + } + + fields() { + const loginFields = []; + const { formState } = this.state; + + if (formState == STATES.SIGN_IN) { + if (_.contains(["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL"], + passwordSignupFields())) { + loginFields.push(this.getUsernameOrEmailField()); + } + + if (passwordSignupFields() === "USERNAME_ONLY") { + loginFields.push(this.getUsernameField()); + } + + if (passwordSignupFields() === "EMAIL_ONLY") { + loginFields.push(this.getEmailField()); + } + + loginFields.push(this.getPasswordField()); + } + + if (formState == STATES.SIGN_UP) { + if (_.contains(["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL", "USERNAME_ONLY"], + passwordSignupFields())) { + loginFields.push(this.getUsernameField()); + } + + if (_.contains(["USERNAME_AND_EMAIL", "EMAIL_ONLY"], passwordSignupFields())) { + loginFields.push(this.getEmailField()); + } + + loginFields.push(this.getPasswordField()); + } + + if (formState == STATES.PASSWORD_RESET) { + loginFields.push(this.getEmailField()); + } + + if (this.showPasswordChangeForm()) { + loginFields.push(this.getPasswordField()); + loginFields.push(this.getNewPasswordField()); + } + + return _.indexBy(loginFields, 'id'); + } + + buttons() { + const { formState, waiting, user } = this.state; + let loginButtons = []; + + if (user) { + loginButtons.push({ + id: 'signOut', + label: t9n('signOut'), + onClick: this.signOut.bind(this) + }); + } + + if (this.showCreateAccountLink()) { + loginButtons.push({ + id: 'switchToSignUp', + label: t9n('signUp'), + type: 'link', + onClick: this.switchToSignUp.bind(this) + }); + } + + if (formState == STATES.SIGN_UP || formState == STATES.PASSWORD_RESET) { + loginButtons.push({ + id: 'switchToSignIn', + label: t9n('signIn'), + type: 'link', + onClick: this.switchToSignIn.bind(this) + }); + } + + if (this.showForgotPasswordLink()) { + loginButtons.push({ + id: 'switchToPasswordReset', + label: t9n('resetYourPassword'), + type: 'link', + onClick: this.switchToPasswordReset.bind(this) + }); + } + + if (formState == STATES.SIGN_UP) { + loginButtons.push({ + id: 'signUp', + label: t9n('signUp'), + type: 'submit', + disabled: waiting, + onClick: this.signUp.bind(this) + }); + } + + if (formState == STATES.SIGN_IN) { + loginButtons.push({ + id: 'signIn', + label: t9n('signIn'), + type: 'submit', + disabled: waiting, + onClick: this.signIn.bind(this) + }); + } + + if (formState == STATES.PASSWORD_RESET) { + loginButtons.push({ + id: 'emailResetLink', + label: t9n('emailResetLink'), + type: 'submit', + disabled: waiting, + onClick: this.passwordReset.bind(this) + }); + } + + if (this.showPasswordChangeForm()) { + loginButtons.push({ + id: 'changePassword', + label: t9n('changePassword'), + disabled: waiting, + onClick: this.passwordChange.bind(this) + }); + } + + // Sort the button array so that the submit button always comes first. + loginButtons.sort((a, b) => { + return (b.type == 'submit') - (a.type == 'submit'); + }); + + return _.indexBy(loginButtons, 'id'); + } + + showPasswordChangeForm() { + return(getLoginServices().indexOf('password') != -1 + && this.state.formState == STATES.PASSWORD_CHANGE); + } + + showCreateAccountLink() { + return this.state.formState == STATES.SIGN_IN && !Accounts._options.forbidClientAccountCreation; + } + + showForgotPasswordLink() { + return !this.state.user + && this.state.formState != STATES.PASSWORD_RESET + && _.contains( + ["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL", "EMAIL_ONLY"], + passwordSignupFields()); + } + + switchToSignUp() { + this.setState({ formState: STATES.SIGN_UP, message: '' }); + } + + switchToSignIn() { + this.setState({ formState: STATES.SIGN_IN, message: '' }); + } + + switchToPasswordReset() { + this.setState({ formState: STATES.PASSWORD_RESET, message: '' }); + } + + signOut() { + Meteor.logout(); + } + + signIn() { + const { + username = null, + email = null, + usernameOrEmail = null, + password + } = this.state; + + let loginSelector; + + if (username !== null) { + if (!this.validateUsername(username)) { + return; + } + else { + loginSelector = { username: username }; + } + } + else if (email !== null) { + if (!this.validateEmail(email)) { + return; + } + else { + loginSelector = { email }; + } + } + else if (usernameOrEmail !== null) { + // XXX not sure how we should validate this. but this seems good enough (for now), + // since an email must have at least 3 characters anyways + if (!this.validateUsername(usernameOrEmail)) { + return; + } + else { + loginSelector = usernameOrEmail; + } + } + else { + throw new Error("Unexpected -- no element to use as a login user selector"); + } + + Meteor.loginWithPassword(loginSelector, password, (error, result) => { + if (error) { + this.showMessage(t9n(`error.accounts.${error.reason}`) || t9n("Unknown error")); + } + else { + this.setState({ formState: STATES.PASSWORD_CHANGE }); + loginResultCallback(this.props.redirect); + } + }); + } + + signUp() { + const options = {}; // to be passed to Accounts.createUser + const { + username = null, + email = null, + usernameOrEmail = null, + password + } = this.state; + + if (username !== null) { + if ( !this.validateUsername(username) ) { + return; + } + else { + options.username = username; + } + } + + if (email !== null) { + if ( !this.validateEmail(email) ){ + return; + } + else { + options.email = email; + } + } + + if (!validatePassword(password)) { + this.showMessage(t9n("error.pwTooShort")); + + return; + } + else { + options.password = password; + } + + this.setState({waiting: true}); + + Accounts.createUser(options, (error)=>{ + if (error) { + this.showMessage(t9n(`error.accounts.${error.reason}`) || t9n("Unknown error")); + } + else { + this.setState({ + formState: STATES.PASSWORD_CHANGE, + message: '' + }); + loginResultCallback(this.props.redirect); + } + + this.setState({ waiting: false }); + }); + } + + passwordReset(){ + const { + email = '', + waiting + } = this.state; + + if (waiting) { + return; + } + + if (email.indexOf('@') !== -1) { + this.setState({ waiting: true }); + + Accounts.forgotPassword({ email: email }, (error) => { + if (error) { + this.showMessage(t9n(`error.accounts.${error.reason}`) || t9n("Unknown error")); + } + else { + this.showMessage(t9n("info.emailSent")); + } + + this.setState({ waiting: false }); + }); + } + else { + this.showMessage(t9n("error.emailInvalid")); + } + } + + passwordChange() { + const { + password, + newPassword + } = this.state; + + if ( !validatePassword(newPassword) ){ + this.showMessage(t9n("error.pwTooShort")); + + return; + } + + Accounts.changePassword(password, newPassword, (error) => { + if (error) { + this.showMessage(t9n(`error.accounts.${error.reason}`) || t9n("Unknown error")); + } + else { + this.showMessage(t9n('info.passwordChanged')); + } + }); + } + + showMessage(message){ + message = message.trim(); + + if (message){ + this.setState({ message: message }); + } + } + + render() { + return ; + } +} + +Accounts.ui.LoginForm = LoginForm; diff --git a/main.js b/main.js new file mode 100644 index 0000000..ffb689a --- /dev/null +++ b/main.js @@ -0,0 +1,9 @@ +import {Accounts} from 'meteor/accounts-base'; +import './imports/accounts_ui.js'; +import './imports/login_session.js'; +import { redirect } from './imports/helpers.js'; + +import './imports/ui/components/LoginForm.jsx'; + +export { Accounts, redirect }; +export default Accounts; diff --git a/package.js b/package.js new file mode 100644 index 0000000..f53670a --- /dev/null +++ b/package.js @@ -0,0 +1,23 @@ +Package.describe({ + name: 'studiointeract:react-accounts-ui', + version: '1.0.1', + summary: 'Accounts UI for React Component in Meteor', + git: 'https://github.com/studiointeract/react-accounts-ui', + documentation: 'README.md' +}); + +Package.onUse(function(api) { + api.versionsFrom('1.3-rc.1'); + api.use('ecmascript'); + api.use('tracker'); + api.use('underscore'); + api.use('accounts-base'); + api.use('check'); + + api.imply('accounts-base'); + + api.use('accounts-oauth', {weak: true}); + api.use('accounts-password', {weak: true}); + + api.mainModule('main.js'); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..cb5a512 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "react-accounts", + "version": "1.0.1", + "description": "Accounts UI for React Component in Meteor", + "repository": { + "type": "git", + "url": "https://github.com/studiointeract/tracker-component.git" + }, + "keywords": [ + "react", + "meteor", + "tracker" + ], + "author": "timbrandin", + "license": "MIT", + "bugs": { + "url": "https://github.com/studiointeract/tracker-component/issues" + }, + "homepage": "https://github.com/studiointeract/tracker-component", + "dependencies": { + "react": "^15.0.0-rc.2", + "react-dom": "^15.0.0-rc.2", + "tracker-component": "^1.3.3-rc.10" + } +}