mirror of
https://github.com/wassname/talk.git
synced 2026-07-17 11:33:39 +08:00
Merge branch 'master' into gdpr-delete
This commit is contained in:
@@ -50,7 +50,6 @@ plugins/*
|
||||
!plugins/talk-plugin-notifications-digest-hourly
|
||||
!plugins/talk-plugin-offtopic
|
||||
!plugins/talk-plugin-permalink
|
||||
!plugins/talk-plugin-profile-settings
|
||||
!plugins/talk-plugin-profile-data
|
||||
!plugins/talk-plugin-remember-sort
|
||||
!plugins/talk-plugin-respect
|
||||
|
||||
@@ -32,7 +32,7 @@ You’ve installed Talk on your server, and you’re preparing to launch it on y
|
||||
|
||||
## End-to-End Testing
|
||||
|
||||
Talk uses [Nightwatch](https://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com).
|
||||
Talk uses [Nightwatch](http://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com).
|
||||
|
||||
[](https://browserstack.com)
|
||||
|
||||
|
||||
+8
-1
@@ -287,8 +287,15 @@ async function createUser() {
|
||||
|
||||
const { email, username, password, role } = answers;
|
||||
|
||||
const ctx = Context.forSystem();
|
||||
|
||||
// Create the user.
|
||||
const user = await UsersService.createLocalUser(email, password, username);
|
||||
const user = await UsersService.createLocalUser(
|
||||
ctx,
|
||||
email,
|
||||
password,
|
||||
username
|
||||
);
|
||||
|
||||
// Set the role.
|
||||
await UsersService.setRole(user.id, role);
|
||||
|
||||
@@ -71,7 +71,6 @@ class OrganizationSettings extends React.Component {
|
||||
await this.props.savePending();
|
||||
this.disableEditing();
|
||||
};
|
||||
|
||||
displayErrors = (errors = []) => (
|
||||
<ul className={styles.errorList}>
|
||||
{errors.map((errKey, i) => (
|
||||
@@ -85,7 +84,6 @@ class OrganizationSettings extends React.Component {
|
||||
render() {
|
||||
const { settings, slotPassthrough, canSave } = this.props;
|
||||
const hasErrors = this.state.errors.length;
|
||||
|
||||
return (
|
||||
<ConfigurePage title={t('configure.organization_information')}>
|
||||
<p>{t('configure.organization_info_copy')}</p>
|
||||
@@ -126,7 +124,7 @@ class OrganizationSettings extends React.Component {
|
||||
[styles.editable]: this.state.editing,
|
||||
})}
|
||||
onChange={this.updateEmail}
|
||||
value={settings.organizationContactEmail}
|
||||
value={settings.organizationContactEmail || ''}
|
||||
id={t('configure.organization_contact_email')}
|
||||
readOnly={!this.state.editing}
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,8 @@ import OrganizationSettings from './OrganizationSettings';
|
||||
import { withRouter } from 'react-router';
|
||||
|
||||
class ConfigureContainer extends React.Component {
|
||||
state = { nextRoute: '' };
|
||||
nextRoute = '';
|
||||
unregisterLeaveHook = null;
|
||||
|
||||
savePending = async () => {
|
||||
await this.props.updateSettings(this.props.pending);
|
||||
@@ -40,18 +41,16 @@ class ConfigureContainer extends React.Component {
|
||||
};
|
||||
|
||||
gotoNextRoute = () => {
|
||||
const { nextRoute } = this.state;
|
||||
if (nextRoute) {
|
||||
this.props.router.push(nextRoute);
|
||||
this.setState({ nextRoute: '' });
|
||||
if (this.nextRoute) {
|
||||
this.props.router.push(this.nextRoute);
|
||||
this.nextRoute = '';
|
||||
}
|
||||
};
|
||||
|
||||
handleSectionChange = async section => {
|
||||
const nextRoute = `/admin/configure/${section}`;
|
||||
|
||||
if (this.shouldShowSaveDialog()) {
|
||||
await this.setState({ nextRoute });
|
||||
if (this.hasPendingData()) {
|
||||
this.nextRoute = nextRoute;
|
||||
this.props.showSaveDialog();
|
||||
} else {
|
||||
// Just go to the section
|
||||
@@ -59,20 +58,37 @@ class ConfigureContainer extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
shouldShowSaveDialog = () => {
|
||||
navigationPrompt = e => {
|
||||
if (this.hasPendingData()) {
|
||||
const confirmationMessage = 'Changes that you made may not be saved.';
|
||||
e.returnValue = confirmationMessage; // Gecko, Trident, Chrome 34+
|
||||
return confirmationMessage; // Gecko, WebKit, Chrome <34
|
||||
}
|
||||
};
|
||||
|
||||
hasPendingData = () => {
|
||||
return !!Object.keys(this.props.pending).length;
|
||||
};
|
||||
|
||||
routeLeave = ({ pathname }) => {
|
||||
if (this.shouldShowSaveDialog()) {
|
||||
this.setState({ nextRoute: pathname });
|
||||
if (this.hasPendingData()) {
|
||||
this.nextRoute = pathname;
|
||||
this.props.showSaveDialog();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.router.setRouteLeaveHook(this.props.route, this.routeLeave);
|
||||
this.unregisterLeaveHook = this.props.router.setRouteLeaveHook(
|
||||
this.props.route,
|
||||
this.routeLeave
|
||||
);
|
||||
window.addEventListener('beforeunload', this.navigationPrompt);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unregisterLeaveHook();
|
||||
window.removeEventListener('beforeunload', this.navigationPrompt);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -4,13 +4,32 @@ import Slot from 'coral-framework/components/Slot';
|
||||
import styles from './Profile.css';
|
||||
import TabPanel from '../containers/TabPanel';
|
||||
|
||||
const Profile = ({ username, emailAddress, root, slotPassthrough }) => {
|
||||
const DefaultProfileHeader = ({ username, emailAddress }) => (
|
||||
<div className={styles.userInfo}>
|
||||
<h2 className={styles.username}>{username}</h2>
|
||||
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
DefaultProfileHeader.propTypes = {
|
||||
username: PropTypes.string,
|
||||
emailAddress: PropTypes.string,
|
||||
};
|
||||
|
||||
const Profile = ({ id, username, emailAddress, root, slotPassthrough }) => {
|
||||
return (
|
||||
<div className="talk-my-profile talk-profile-container">
|
||||
<div className={styles.userInfo}>
|
||||
<h2 className={styles.username}>{username}</h2>
|
||||
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
|
||||
</div>
|
||||
<Slot
|
||||
fill="profileHeader"
|
||||
size={1}
|
||||
defaultComponent={DefaultProfileHeader}
|
||||
passthrough={{
|
||||
...slotPassthrough,
|
||||
id,
|
||||
username,
|
||||
emailAddress,
|
||||
}}
|
||||
/>
|
||||
<Slot fill="profileSections" passthrough={slotPassthrough} />
|
||||
<TabPanel root={root} slotPassthrough={slotPassthrough} />
|
||||
</div>
|
||||
@@ -18,6 +37,7 @@ const Profile = ({ username, emailAddress, root, slotPassthrough }) => {
|
||||
};
|
||||
|
||||
Profile.propTypes = {
|
||||
id: PropTypes.string,
|
||||
username: PropTypes.string,
|
||||
emailAddress: PropTypes.string,
|
||||
root: PropTypes.object,
|
||||
|
||||
@@ -30,6 +30,7 @@ class ProfileContainer extends Component {
|
||||
|
||||
return (
|
||||
<Profile
|
||||
id={me.id}
|
||||
username={me.username}
|
||||
emailAddress={emailAddress}
|
||||
root={root}
|
||||
@@ -53,6 +54,15 @@ const withProfileQuery = withQuery(
|
||||
me {
|
||||
id
|
||||
username
|
||||
state {
|
||||
status {
|
||||
username {
|
||||
history {
|
||||
created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
...${getDefinitionName(TabPanel.fragments.root)}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
|
||||
@@ -177,8 +177,8 @@ button.comment__action-button[disabled],
|
||||
}
|
||||
|
||||
.talk-plugin-flags-popup-header {
|
||||
font-weight: bolder;
|
||||
font-size: 1.33rem;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export default {
|
||||
'UpdateSettingsResponse',
|
||||
'RequestAccountDeletionResponse',
|
||||
'RequestDownloadLinkResponse',
|
||||
'CancelAccountDeletionResponse'
|
||||
'CancelAccountDeletionResponse',
|
||||
'ChangePasswordResponse'
|
||||
),
|
||||
};
|
||||
|
||||
@@ -623,6 +623,27 @@ export const withUpdateSettings = withMutation(
|
||||
}
|
||||
);
|
||||
|
||||
export const withChangePassword = withMutation(
|
||||
gql`
|
||||
mutation ChangePassword($input: ChangePasswordInput!) {
|
||||
changePassword(input: $input) {
|
||||
...ChangePasswordResponse
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
props: ({ mutate }) => ({
|
||||
changePassword: input => {
|
||||
return mutate({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
export const withRequestAccountDeletion = withMutation(
|
||||
gql`
|
||||
mutation RequestAccountDeletion {
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'moment/locale/da';
|
||||
import 'moment/locale/de';
|
||||
import 'moment/locale/es';
|
||||
import 'moment/locale/fr';
|
||||
import 'moment/locale/nl';
|
||||
import 'moment/locale/pt-br';
|
||||
|
||||
import { createStorage } from 'coral-framework/services/storage';
|
||||
@@ -18,10 +19,10 @@ import daTA from 'timeago.js/locales/da';
|
||||
import deTA from 'timeago.js/locales/de';
|
||||
import esTA from 'timeago.js/locales/es';
|
||||
import frTA from 'timeago.js/locales/fr';
|
||||
import nlTA from 'timeago.js/locales/nl';
|
||||
import pt_BRTA from 'timeago.js/locales/pt_BR';
|
||||
import zh_CNTA from 'timeago.js/locales/zh_CN';
|
||||
import zh_TWTA from 'timeago.js/locales/zh_TW';
|
||||
import nl from 'timeago.js/locales/nl';
|
||||
|
||||
import ar from '../../../locales/ar.yml';
|
||||
import en from '../../../locales/en.yml';
|
||||
@@ -29,10 +30,10 @@ import da from '../../../locales/da.yml';
|
||||
import de from '../../../locales/de.yml';
|
||||
import es from '../../../locales/es.yml';
|
||||
import fr from '../../../locales/fr.yml';
|
||||
import nl_NL from '../../../locales/nl_NL.yml';
|
||||
import pt_BR from '../../../locales/pt_BR.yml';
|
||||
import zh_CN from '../../../locales/zh_CN.yml';
|
||||
import zh_TW from '../../../locales/zh_TW.yml';
|
||||
import nl_NL from '../../../locales/nl_NL.yml';
|
||||
|
||||
const defaultLanguage = process.env.TALK_DEFAULT_LANG;
|
||||
const translations = {
|
||||
@@ -112,10 +113,10 @@ export function setupTranslations() {
|
||||
ta.register('da', daTA);
|
||||
ta.register('de', deTA);
|
||||
ta.register('fr', frTA);
|
||||
ta.register('nl_NL', nlTA);
|
||||
ta.register('pt_BR', pt_BRTA);
|
||||
ta.register('zh_CN', zh_CNTA);
|
||||
ta.register('zh_TW', zh_TWTA);
|
||||
ta.register('nl_NL', nl);
|
||||
|
||||
timeagoInstance = ta();
|
||||
}
|
||||
|
||||
@@ -245,7 +245,11 @@ export function getTotalReactionsCount(actionSummaries) {
|
||||
|
||||
// Like lodash merge but does not recurse into arrays.
|
||||
export function mergeExcludingArrays(objValue, srcValue) {
|
||||
if (typeof srcValue === 'object' && !Array.isArray(srcValue)) {
|
||||
if (
|
||||
typeof srcValue === 'object' &&
|
||||
!Array.isArray(srcValue) &&
|
||||
srcValue !== null
|
||||
) {
|
||||
return assignWith({}, objValue, srcValue, mergeExcludingArrays);
|
||||
}
|
||||
return srcValue;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import get from 'lodash/get';
|
||||
import moment from 'moment';
|
||||
|
||||
/**
|
||||
* getReliability
|
||||
@@ -33,3 +34,18 @@ export const isSuspended = user => {
|
||||
export const isBanned = user => {
|
||||
return get(user, 'state.status.banned.status');
|
||||
};
|
||||
|
||||
/**
|
||||
* canUsernameBeUpdated
|
||||
* retrieves boolean whether a username can be updated or not
|
||||
*/
|
||||
|
||||
export const canUsernameBeUpdated = status => {
|
||||
const oldestEditTime = moment()
|
||||
.subtract(14, 'days')
|
||||
.toDate();
|
||||
|
||||
return !status.username.history.some(({ created_at }) =>
|
||||
moment(created_at).isAfter(oldestEditTime)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
box-sizing: border-box;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
padding: 20px 10px;
|
||||
padding: 10px 10px;
|
||||
z-index: 300;
|
||||
right: 1%;
|
||||
}
|
||||
|
||||
@@ -170,8 +170,9 @@ moderators.
|
||||
|
||||
All your team and commenters show in the People sub-tab. From here, you can
|
||||
manage your team members’ roles (Admins, Moderators, Staff), as well as search
|
||||
for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.). ###
|
||||
Configure
|
||||
for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.).
|
||||
|
||||
### Configure
|
||||
|
||||
See [Configuring Talk](/talk/configuring-talk/).
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@ By default, Talk has various plugins provided by default. We can see this in `pl
|
||||
"talk-plugin-sort-most-respected",
|
||||
"talk-plugin-sort-newest",
|
||||
"talk-plugin-sort-oldest",
|
||||
"talk-plugin-viewing-options",
|
||||
"talk-plugin-profile-settings"
|
||||
"talk-plugin-viewing-options"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -65,10 +65,6 @@ First, you'll enable `talk-plugin-author-menu`, as this houses the Ignore button
|
||||
|
||||
And then we will enable the Ignore User plugin: `talk-plugin-ignore-user`.
|
||||
|
||||
And finally, we will need to enable Profile Settings; this is the tab on My Profile > Settings where commenters can manage their Ignored Users list.
|
||||
|
||||
`talk-plugin-profile-settings`
|
||||
|
||||
### Featured Comments
|
||||
|
||||
To enable the featuring of comments, you'll need to activate `talk-plugin-featured-comments`. If you would like the Featured Comments tab to be the default tab you land on for the stream, you will need to set the default tab ENV variable:
|
||||
|
||||
@@ -36,10 +36,6 @@ Adding the `talk-plugin-notifications` plugin will also enable the `notification
|
||||
|
||||
See https://github.com/coralproject/talk/blob/8b669a31c551a042f0f079d8cfc16825673eb8f0/plugins/talk-plugin-notifications-reply/index.js for an example.
|
||||
|
||||
### Commenter Notification Settings
|
||||
|
||||
Note that notifications REQUIRE the `talk-plugin-profile-settings` plugin; this is where on My Profile commenters will enable and manage their notification settings.
|
||||
|
||||
### Notification Categories
|
||||
|
||||
Talk currently supports the following Notifications options out of the box:
|
||||
|
||||
@@ -10,6 +10,9 @@ const secrets = require('../secrets');
|
||||
// Errors.
|
||||
const errors = require('../errors');
|
||||
|
||||
// URLs.
|
||||
const url = require('../url');
|
||||
|
||||
// Graph.
|
||||
const { getBroker } = require('./subscriptions/broker');
|
||||
const { getPubsub } = require('./subscriptions/pubsub');
|
||||
@@ -58,6 +61,7 @@ const defaultConnectors = {
|
||||
errors,
|
||||
config,
|
||||
secrets,
|
||||
url,
|
||||
models: {
|
||||
Action,
|
||||
Asset,
|
||||
|
||||
+39
-1
@@ -9,6 +9,7 @@ const {
|
||||
SET_USER_SUSPENSION_STATUS,
|
||||
UPDATE_USER_ROLES,
|
||||
DELETE_OTHER_USER,
|
||||
CHANGE_PASSWORD,
|
||||
} = require('../../perms/constants');
|
||||
|
||||
const setUserUsernameStatus = async (ctx, id, status) => {
|
||||
@@ -159,6 +160,38 @@ const delUser = async (ctx, id) => {
|
||||
await user.remove();
|
||||
};
|
||||
|
||||
const changeUserPassword = async (ctx, oldPassword, newPassword) => {
|
||||
const {
|
||||
user,
|
||||
loaders: { Settings },
|
||||
connectors: { services: { I18n } },
|
||||
} = ctx;
|
||||
|
||||
// Verify the old password.
|
||||
const validPassword = await user.verifyPassword(oldPassword);
|
||||
if (!validPassword) {
|
||||
throw new ErrNotAuthorized();
|
||||
}
|
||||
|
||||
// Change the users password now.
|
||||
await Users.changePassword(user.id, newPassword);
|
||||
|
||||
// Get some context for the email to be sent.
|
||||
const { organizationName, organizationContactEmail } = await Settings.load([
|
||||
'organizationName',
|
||||
'organizationContactEmail',
|
||||
]);
|
||||
|
||||
// Send the password change email.
|
||||
await Users.sendEmail(user, {
|
||||
template: 'plain',
|
||||
locals: {
|
||||
body: I18n.t('email.password_change.body', organizationContactEmail),
|
||||
},
|
||||
subject: I18n.t('email.password_change.subject', organizationName),
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = ctx => {
|
||||
let mutators = {
|
||||
User: {
|
||||
@@ -171,7 +204,7 @@ module.exports = ctx => {
|
||||
setUsername: () => Promise.reject(new ErrNotAuthorized()),
|
||||
stopIgnoringUser: () => Promise.reject(new ErrNotAuthorized()),
|
||||
del: () => Promise.reject(new ErrNotAuthorized()),
|
||||
delSelf: () => Promise.reject(new ErrNotAuthorized()),
|
||||
changePassword: () => Promise.reject(new ErrNotAuthorized()),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -211,6 +244,11 @@ module.exports = ctx => {
|
||||
if (ctx.user.can(DELETE_OTHER_USER)) {
|
||||
mutators.User.del = id => delUser(ctx, id);
|
||||
}
|
||||
|
||||
if (ctx.user.can(CHANGE_PASSWORD)) {
|
||||
mutators.User.changePassword = ({ oldPassword, newPassword }) =>
|
||||
changeUserPassword(ctx, oldPassword, newPassword);
|
||||
}
|
||||
}
|
||||
|
||||
return mutators;
|
||||
|
||||
@@ -139,6 +139,9 @@ const RootMutation = {
|
||||
delUser: async (_, { id }, { mutators: { User } }) => {
|
||||
await User.del(id);
|
||||
},
|
||||
changePassword: async (_, { input }, { mutators: { User } }) => {
|
||||
await User.changePassword(input);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = RootMutation;
|
||||
|
||||
@@ -1445,6 +1445,21 @@ type DelUserResponse implements Response {
|
||||
errors: [UserError!]
|
||||
}
|
||||
|
||||
input ChangePasswordInput {
|
||||
# oldPassword is the previous password set on the account. An incorrect
|
||||
# password here will result in an unauthorized error being thrown.
|
||||
oldPassword: String!
|
||||
|
||||
# newPassword is the password we're changing it to.
|
||||
newPassword: String!
|
||||
}
|
||||
|
||||
type ChangePasswordResponse implements Response {
|
||||
|
||||
# An array of errors relating to the mutation that occurred.
|
||||
errors: [UserError!]
|
||||
}
|
||||
|
||||
# All mutations for the application are defined on this object.
|
||||
type RootMutation {
|
||||
|
||||
@@ -1545,6 +1560,10 @@ type RootMutation {
|
||||
|
||||
# delUser will delete the user with the specified id.
|
||||
delUser(id: ID!): DelUserResponse
|
||||
|
||||
# changePassword allows the current user to change their password that have an
|
||||
# associated local user account.
|
||||
changePassword(input: ChangePasswordInput!): ChangePasswordResponse
|
||||
}
|
||||
|
||||
type UsernameChangedPayload {
|
||||
|
||||
+10
-6
@@ -123,7 +123,7 @@ en:
|
||||
custom_css_url: "Custom CSS URL"
|
||||
custom_css_url_desc: "URL of a CSS stylesheet that will override default Embed Stream styles. Can be internal or external."
|
||||
days: Days
|
||||
description: "As an admin, you can customize the settings for the comment stream for this story:"
|
||||
description: "Change the comment settings on this story."
|
||||
domain_list_text: "Enter the domains you would like to permit for Talk e.g. your local staging and production environments (ex. localhost:3000 staging.domain.com domain.com)."
|
||||
domain_list_title: "Permitted Domains"
|
||||
edit_info: "Edit Info"
|
||||
@@ -164,7 +164,7 @@ en:
|
||||
tech_settings: "Tech Settings"
|
||||
organization_information: "Organization information"
|
||||
organization_info_copy: "We use this information in email notifications generated by Talk. This connects the messages to your organization, and provides a way for users to contact you if they have an issue with their account."
|
||||
organization_info_copy_2: "We recommend creating a generic email account (eg. community@yournewsroom.com) for this purpuse. This means it can remain consistent over time, and doesn't expose a name that users could target if their account were blocked."
|
||||
organization_info_copy_2: "We recommend creating a generic email account (eg. community@yournewsroom.com) for this purpose. This means it can remain consistent over time, and doesn't expose a name that users could target if their account were blocked."
|
||||
organization_details: "Organization Details"
|
||||
organization_name: "Organization Name"
|
||||
organization_contact_email: "Organization Contact Email"
|
||||
@@ -218,6 +218,10 @@ en:
|
||||
we_received_a_request: "We received a request to reset your password. If you did not request this change, you can ignore this email."
|
||||
if_you_did: "If you did,"
|
||||
please_click: "please click here to reset password"
|
||||
subject: "Password reset"
|
||||
password_change:
|
||||
subject: "{0} password change"
|
||||
body: "The password on your account has been changed.\n\nIf you did not request this change, please contact us at {0}."
|
||||
embedlink:
|
||||
copy: "Copy to Clipboard"
|
||||
error:
|
||||
@@ -233,7 +237,7 @@ en:
|
||||
RATE_LIMIT_EXCEEDED: "Rate limit exceeded"
|
||||
USERNAME_IN_USE: "Username already in use"
|
||||
USERNAME_REQUIRED: "Must input a username"
|
||||
EMAIL_NOT_VERIFIED: "E-mail address not verified"
|
||||
EMAIL_NOT_VERIFIED: "Email address not verified"
|
||||
EDIT_WINDOW_ENDED: "You can no longer edit this comment. The time window to do so has expired."
|
||||
EDIT_USERNAME_NOT_AUTHORIZED: "You do not have permission to update your username."
|
||||
SAME_USERNAME_PROVIDED: "You must submit a different username."
|
||||
@@ -250,8 +254,8 @@ en:
|
||||
email: "Not a valid E-Mail"
|
||||
confirm_password: "Passwords don't match. Please check again"
|
||||
network_error: "Failed to connect to server. Check your internet connection and try again."
|
||||
email_not_verified: "E-mail address {0} not verified."
|
||||
email_password: "E-mail and/or password combination incorrect."
|
||||
email_not_verified: "Email address {0} not verified."
|
||||
email_password: "Email and/or password combination incorrect."
|
||||
organization_name: "Organization name must only contain letters or numbers."
|
||||
organization_contact_email: "Organization email is not valid."
|
||||
password: "Password must be at least 8 characters"
|
||||
@@ -440,7 +444,7 @@ en:
|
||||
title_reject: "We noticed you rejected a username"
|
||||
suspend_user: "Suspend User"
|
||||
yes_suspend: "Yes suspend"
|
||||
email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns."
|
||||
email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please email us if you have any questions or concerns."
|
||||
write_message: "Write a message"
|
||||
send: Send
|
||||
thank_you: "We value your safety and feedback. A moderator will review your report."
|
||||
|
||||
+2
-1
@@ -120,7 +120,7 @@ es:
|
||||
custom_css_url: "URL CSS a medida"
|
||||
custom_css_url_desc: "URL de una hoja de estilo que va a sobrescribir los estilos por defecto del hilo de comentarios. Puede ser interna o externa."
|
||||
days: Días
|
||||
description: "Como Administrador/a puedes modificar la configuración de los comentarios en este artículo"
|
||||
description: "Modificar la configuración de los comentarios en este artículo."
|
||||
domain_list_text: "Agrega dominios permitidos a Talk, por ejemplo tu localhost, staging y ambientes de producción (ej. localhost:3000, staging.domain.com, domain.com)."
|
||||
domain_list_title: "Dominios Permitidos"
|
||||
edit_info: "Editar Información"
|
||||
@@ -215,6 +215,7 @@ es:
|
||||
we_received_a_request: "Recibimos un pedido para resetear su contraseña. Si no ha pedido ese cambio, por favor ignorar el correo. "
|
||||
if_you_did: "Si lo hizo,"
|
||||
please_click: "por favor cliquea aqui para resetear la contraseña."
|
||||
subject: "Recuperar contraseña"
|
||||
embedlink:
|
||||
copy: "Copiar al portapapeles"
|
||||
error:
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ fr:
|
||||
custom_css_url: "URL CSS personnalisée"
|
||||
custom_css_url_desc: "URL d'une feuille de style CSS qui remplacera les styles par défaut d'intégration des commentaires. Peut être interne ou externe."
|
||||
days: Journées
|
||||
description: "En tant qu'administrateur, vous pouvez personnaliser les paramètres du fil de commentaires pour cet élément."
|
||||
description: "Personnaliser les paramètres du fil de commentaires pour cet élément."
|
||||
domain_list_text: "Entrez les domaines que vous souhaitez autoriser pour Talk, par exemple vos environnements locaux de production et de production (ex : localhost: 3000 staging.domain.com domain.com)."
|
||||
domain_list_title: "Domaines autorisés"
|
||||
edit_comment_timeframe_heading: "Modifier l'horodatage des commentaires"
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ pt_BR:
|
||||
custom_css_url: "URL para CSS customizado"
|
||||
custom_css_url_desc: "URL de uma folha de estilo CSS para substituir os estilos padrão dos comentários embutidos. Pode ser interno ou externo."
|
||||
days: Dias
|
||||
description: "Como administrador, você pode personalizar as configurações da lista de comentários para esta história:"
|
||||
description: "Personalizar as configurações da lista de comentários para esta história."
|
||||
domain_list_text: "Insira os domínios que você gostaria de permitir para o Talk, como seus ambientes de desenvolvimento, teste ou produção (ej. localhost:3000 staging.domain.com domain.com)."
|
||||
domain_list_title: "Domínios permitidos"
|
||||
edit_comment_timeframe_heading: "Período de tempo para editar comentários"
|
||||
|
||||
+1
-14
@@ -1,5 +1,3 @@
|
||||
const { CREATE_MONGO_INDEXES } = require('../../config');
|
||||
|
||||
const Action = require('./action');
|
||||
const Asset = require('./asset');
|
||||
const Comment = require('./comment');
|
||||
@@ -7,15 +5,4 @@ const Migration = require('./migration');
|
||||
const Setting = require('./setting');
|
||||
const User = require('./user');
|
||||
|
||||
const schema = { Action, Asset, Comment, Migration, Setting, User };
|
||||
|
||||
// Provide the schema to each of the plugins so that they can add in indexes if
|
||||
// it is enabled.
|
||||
if (CREATE_MONGO_INDEXES) {
|
||||
const plugins = require('../../services/plugins');
|
||||
plugins.get('server', 'indexes').map(({ indexes }) => {
|
||||
indexes(schema);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = schema;
|
||||
module.exports = { Action, Asset, Comment, Migration, Setting, User };
|
||||
|
||||
@@ -19,4 +19,5 @@ module.exports = {
|
||||
UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS',
|
||||
UPDATE_SETTINGS: 'UPDATE_SETTINGS',
|
||||
DELETE_OTHER_USER: 'DELETE_OTHER_USER',
|
||||
CHANGE_PASSWORD: 'CHANGE_PASSWORD',
|
||||
};
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
const { get, isString } = require('lodash');
|
||||
const moment = require('moment');
|
||||
const { check } = require('../utils');
|
||||
const types = require('../constants');
|
||||
|
||||
module.exports = (user, perm) => {
|
||||
switch (perm) {
|
||||
case types.CHANGE_PASSWORD:
|
||||
// Only users with a local account where they have a password set can
|
||||
// actually change their password.
|
||||
return (
|
||||
user.profiles.some(({ provider }) => provider === 'local') &&
|
||||
isString(user.password) &&
|
||||
user.password.length > 0
|
||||
);
|
||||
|
||||
case types.CHANGE_USERNAME:
|
||||
return user.status.username.status === 'REJECTED';
|
||||
|
||||
case types.SET_USERNAME:
|
||||
return user.status.username.status === 'UNSET';
|
||||
case types.SET_USERNAME: {
|
||||
// Only users who have their usernames rejected or those users who
|
||||
// not changed their usernames within 14 days can change their usernames.
|
||||
const deadline = moment().subtract(14, 'days');
|
||||
return (
|
||||
user.status.username.status === 'UNSET' ||
|
||||
get(user, 'status.username.history', []).every(({ created_at }) =>
|
||||
moment(created_at).isBefore(deadline)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
case types.CREATE_COMMENT:
|
||||
case types.CREATE_ACTION:
|
||||
|
||||
@@ -28,5 +28,7 @@ export {
|
||||
withRequestAccountDeletion,
|
||||
withRequestDownloadLink,
|
||||
withCancelAccountDeletion,
|
||||
withChangePassword,
|
||||
withChangeUsername,
|
||||
} from 'coral-framework/graphql/mutations';
|
||||
export { compose } from 'recompose';
|
||||
|
||||
@@ -2,11 +2,26 @@ const { SEARCH_OTHER_USERS } = require('../../../perms/constants');
|
||||
const { ErrNotFound, ErrAlreadyExists } = require('../../../errors');
|
||||
const pluralize = require('pluralize');
|
||||
const sc = require('snake-case');
|
||||
const { CREATE_MONGO_INDEXES } = require('../../../config');
|
||||
const Comment = require('models/comment');
|
||||
|
||||
function getReactionConfig(reaction) {
|
||||
// Ensure that the reaction is a lowercase string.
|
||||
reaction = reaction.toLowerCase();
|
||||
|
||||
if (CREATE_MONGO_INDEXES) {
|
||||
// Create the index on the comment model based on the reaction config.
|
||||
Comment.collection.createIndex(
|
||||
{
|
||||
created_at: 1,
|
||||
[`action_counts.${sc(reaction)}`]: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const reactionPlural = pluralize(reaction);
|
||||
const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1);
|
||||
const REACTION = reaction.toUpperCase();
|
||||
@@ -114,17 +129,6 @@ function getReactionConfig(reaction) {
|
||||
|
||||
return {
|
||||
typeDefs,
|
||||
indexes: ({ Comment }) => {
|
||||
Comment.index(
|
||||
{
|
||||
created_at: 1,
|
||||
[`action_counts.${sc(reaction)}`]: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
},
|
||||
context: {
|
||||
Sort: () => ({
|
||||
Comments: {
|
||||
|
||||
@@ -6,6 +6,8 @@ import Login from './login/containers/Main';
|
||||
import reducer from './login/reducer';
|
||||
import DeleteMyAccount from './profile-settings/containers/DeleteMyAccount';
|
||||
import AccountDeletionRequestedSign from './stream/containers/AccountDeletionRequestedSign';
|
||||
import ChangePassword from './profile-settings/containers/ChangePassword';
|
||||
import ChangeUsername from './profile-settings/containers/ChangeUsername';
|
||||
|
||||
export default {
|
||||
reducer,
|
||||
@@ -18,6 +20,7 @@ export default {
|
||||
SetUsernameDialog,
|
||||
],
|
||||
login: [Login],
|
||||
profileSettings: [DeleteMyAccount],
|
||||
profileHeader: [ChangeUsername],
|
||||
profileSettings: [ChangePassword, DeleteMyAccount],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -103,8 +103,7 @@ class SignUp extends React.Component {
|
||||
/>
|
||||
{passwordError && (
|
||||
<span className={styles.hint}>
|
||||
{' '}
|
||||
Password must be at least 8 characters.{' '}
|
||||
{t('talk-plugin-auth.login.password_error')}
|
||||
</span>
|
||||
)}
|
||||
<TextField
|
||||
|
||||
@@ -58,8 +58,9 @@ da:
|
||||
sign_in: "Sign in"
|
||||
sign_in_to_join: "Sign in to join the conversation"
|
||||
or: "Or"
|
||||
email: "E-mail Address"
|
||||
email: "Email Address"
|
||||
password: "Password"
|
||||
password_error: "Password must be at least 8 characters."
|
||||
forgot_your_pass: "Forgot your password?"
|
||||
need_an_account: "Need an account?"
|
||||
register: "Register"
|
||||
@@ -101,8 +102,9 @@ en:
|
||||
sign_in: "Sign in"
|
||||
sign_in_to_join: "Sign in to join the conversation"
|
||||
or: "Or"
|
||||
email: "E-mail Address"
|
||||
email: "Email Address"
|
||||
password: "Password"
|
||||
password_error: "Password must be at least 8 characters."
|
||||
forgot_your_pass: "Forgot your password?"
|
||||
need_an_account: "Need an account?"
|
||||
register: "Register"
|
||||
@@ -131,6 +133,29 @@ en:
|
||||
username: Username
|
||||
write_your_username: "Edit your username"
|
||||
your_username: "Your username appears on every comment you post."
|
||||
change_password:
|
||||
change_password: "Change Password"
|
||||
passwords_dont_match: "Passwords don`t match"
|
||||
required_field: "This field is required"
|
||||
forgot_password: "Forgot your password?"
|
||||
save: "Save"
|
||||
cancel: "Cancel"
|
||||
edit: "Edit"
|
||||
changed_password_msg: "Changed Password - Your password has been successfully changed"
|
||||
change_username:
|
||||
change_username_note: "Usernames can be changed every 14 days"
|
||||
save: "Save"
|
||||
edit_profile: "Edit Profile"
|
||||
cancel: "Cancel"
|
||||
confirm_username_change: "Confirm Username Change"
|
||||
description: "You are attempting to change your username. Your new username will appear on all of your past and future comments."
|
||||
old_username: "Old Username"
|
||||
new_username: "New Username"
|
||||
bottom_note: "Note: You will not be able to change your username again for 14 days"
|
||||
confirm_changes: "Confirm Changes"
|
||||
username_does_not_match: "Username does not match"
|
||||
changed_username_success_msg: "Username Changed - Your username has been successfully changed. You will not be able to change your user name for 14 days."
|
||||
change_username_attempt: "Username can't be updated. Usernames can be changed every 14 days"
|
||||
de:
|
||||
talk-plugin-auth:
|
||||
login:
|
||||
@@ -192,6 +217,7 @@ es:
|
||||
or: "O"
|
||||
email: "Dirección de Correo"
|
||||
password: "Contraseña"
|
||||
password_error: "La contraseña debe tener al menos 8 caracteres."
|
||||
forgot_your_pass: "¿Has olvidado tu contraseña?"
|
||||
need_an_account: "¿Necesitas una cuenta?"
|
||||
register: "Registrar"
|
||||
@@ -222,6 +248,29 @@ es:
|
||||
username: Nombre
|
||||
write_your_username: "Edita tu nombre"
|
||||
your_username: "Tu nombre aparece en cada comentario que publiques."
|
||||
change_password:
|
||||
change_password: "Cambiar Contraseña"
|
||||
passwords_dont_match: "Las contraseñas no coinciden"
|
||||
required_field: "Este campo es requerido"
|
||||
forgot_password: "Olvidaste tu contraseña?"
|
||||
save: "Guardar"
|
||||
cancel: "Cancelar"
|
||||
edit: "Editar"
|
||||
changed_password_msg: "Contraseña Actualizada - Tu contraseña ha sido exitosamente actualizada"
|
||||
change_username:
|
||||
change_username_note: "El usuario puede ser cambiado cada 14 días"
|
||||
save: "Guardar"
|
||||
edit_profile: "Editar Perfil"
|
||||
cancel: "Cancelar"
|
||||
confirm_username_change: "Confirmar Cambio de Usuario"
|
||||
description: "Estás intentando cambiar tu usuario. Tu nuevo usuario aparecerá en todos tus pasados y futuros comentarios."
|
||||
old_username: "Usuario viejo"
|
||||
new_username: "Usuario nuevo"
|
||||
bottom_note: "Nota: No podrás cambiar tu usuario por 14 días"
|
||||
confirm_changes: "Confirmar Cambios"
|
||||
username_does_not_match: "El usuario no coincide"
|
||||
changed_username_success_msg: "Usuario Actualizado - Tu usuario ha sido exitosamente actualizado. No podrás cambiar el usuario por 14 días."
|
||||
change_username_attempt: "El usuario no puede ser actualizado. Los usuarios pueden ser cambiados cada 14 días."
|
||||
fr:
|
||||
talk-plugin-auth:
|
||||
login:
|
||||
@@ -324,7 +373,7 @@ pt_BR:
|
||||
sign_in: "Sign in"
|
||||
sign_in_to_join: "Sign in to join the conversation"
|
||||
or: "Or"
|
||||
email: "E-mail Address"
|
||||
email: "Email Address"
|
||||
password: "Password"
|
||||
forgot_your_pass: "Forgot your password?"
|
||||
need_an_account: "Need an account?"
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<head>
|
||||
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
|
||||
<title><%= t('talk-plugin-notifications.unsubscribe_page.unsubscribe') %></title>
|
||||
<%- include(root + '/partials/head') %>
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
|
||||
<link rel="stylesheet" href="<%= BASE_PATH %>public/css/admin.css">
|
||||
<%- include(root + '/partials/head') %>
|
||||
</head>
|
||||
<body class="confirm-email-page">
|
||||
<div id="root">
|
||||
|
||||
@@ -20,4 +20,5 @@ their profile tab in the comment stream. Once clicked, an email will be sent
|
||||
that contains a download link. Only one link can be generated every 7 days, and
|
||||
the link will be valid for 24 hours.
|
||||
|
||||
The downloaded zip file will contain the users comments in a CSV format.
|
||||
The downloaded zip file will contain all the users comments in a CSV format
|
||||
including those that have been rejected, withheld, or still in premod.
|
||||
|
||||
@@ -1 +1 @@
|
||||
<p><%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <a href="<%= BASE_URL %>account/download#<%= token %>"><%= t('email.download.download_archive') %></a></p>
|
||||
<p><%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <a href="<%= downloadLandingURL %>"><%= t('email.download.download_archive') %></a></p>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %>
|
||||
|
||||
<%= BASE_URL %>account/download#<%= token %>
|
||||
<%= downloadLandingURL %>
|
||||
|
||||
@@ -6,18 +6,41 @@ const {
|
||||
ErrDeletionAlreadyScheduled,
|
||||
ErrDeletionNotScheduled,
|
||||
} = require('./errors');
|
||||
const { ErrNotAuthorized } = require('errors');
|
||||
const { ErrNotAuthorized, ErrMaxRateLimit } = require('errors');
|
||||
const { URL } = require('url');
|
||||
|
||||
// generateDownloadLinks will generate a signed set of links for a given user to
|
||||
// download an archive of their data.
|
||||
async function generateDownloadLinks(ctx, userID) {
|
||||
const { connectors: { url: { BASE_URL }, secrets } } = ctx;
|
||||
|
||||
// Generate a token for the download link.
|
||||
const token = await secrets.jwt.sign(
|
||||
{ user: userID },
|
||||
{ jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT }
|
||||
);
|
||||
|
||||
// Generate the url that a user can land on.
|
||||
const downloadLandingURL = new URL('account/download', BASE_URL);
|
||||
downloadLandingURL.hash = token;
|
||||
|
||||
// Generate the url that the API calls to download the actual zip.
|
||||
const downloadFileURL = new URL('api/v1/account/download', BASE_URL);
|
||||
downloadFileURL.searchParams.set('token', token);
|
||||
|
||||
return {
|
||||
downloadLandingURL: downloadLandingURL.href,
|
||||
downloadFileURL: downloadFileURL.href,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendDownloadLink(ctx) {
|
||||
const {
|
||||
user,
|
||||
loaders: { Settings },
|
||||
connectors: { services: { Users, I18n, Limit }, models: { User } },
|
||||
} = ctx;
|
||||
|
||||
async function sendDownloadLink({
|
||||
user,
|
||||
loaders: { Settings },
|
||||
connectors: {
|
||||
errors,
|
||||
secrets,
|
||||
services: { Users, I18n, Limit },
|
||||
models: { User },
|
||||
},
|
||||
}) {
|
||||
// downloadLinkLimiter can be used to limit downloads for the user's data to
|
||||
// once every 7 days.
|
||||
const downloadLinkLimiter = new Limit('profileDataDownloadLimiter', 1, '7d');
|
||||
@@ -26,7 +49,7 @@ async function sendDownloadLink({
|
||||
// 7 days.
|
||||
const attempts = await downloadLinkLimiter.get(user.id);
|
||||
if (attempts && attempts >= 1) {
|
||||
throw errors.ErrMaxRateLimit;
|
||||
throw new ErrMaxRateLimit();
|
||||
}
|
||||
|
||||
// Check if the lastAccountDownload time is within 7 days.
|
||||
@@ -36,7 +59,7 @@ async function sendDownloadLink({
|
||||
.add(7, 'days')
|
||||
.isAfter(moment())
|
||||
) {
|
||||
throw errors.ErrMaxRateLimit;
|
||||
throw new ErrMaxRateLimit();
|
||||
}
|
||||
|
||||
// The account currently does not have a download link, let's record the
|
||||
@@ -44,21 +67,18 @@ async function sendDownloadLink({
|
||||
// now.
|
||||
await downloadLinkLimiter.test(user.id);
|
||||
|
||||
// Generate a token for the download link.
|
||||
const token = await secrets.jwt.sign(
|
||||
{ user: user.id },
|
||||
{ jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT }
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Generate the download links.
|
||||
const { downloadLandingURL } = await generateDownloadLinks(ctx, user.id);
|
||||
|
||||
const { organizationName } = await Settings.load('organizationName');
|
||||
|
||||
// Send the download link via the user's attached email account.
|
||||
await Users.sendEmail(user, {
|
||||
template: 'download',
|
||||
locals: {
|
||||
token,
|
||||
downloadLandingURL,
|
||||
organizationName,
|
||||
now,
|
||||
},
|
||||
@@ -125,3 +145,20 @@ module.exports = ctx =>
|
||||
cancelDeletion: () => Promise.reject(new ErrNotAuthorized()),
|
||||
},
|
||||
};
|
||||
// downloadUser will return the download file url that can be used to directly
|
||||
// download the archive.
|
||||
async function downloadUser(ctx, userID) {
|
||||
const { downloadFileURL } = await generateDownloadLinks(ctx, userID);
|
||||
return downloadFileURL;
|
||||
}
|
||||
|
||||
module.exports = ctx => ({
|
||||
User: {
|
||||
requestDownloadLink: () => sendDownloadLink(ctx),
|
||||
download:
|
||||
// Only ADMIN users can execute an account download.
|
||||
ctx.user && ctx.user.role === 'ADMIN'
|
||||
? userID => downloadUser(ctx, userID)
|
||||
: () => Promise.reject(new ErrNotAuthorized()),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,6 +11,9 @@ module.exports = {
|
||||
cancelAccountDeletion: async (_, args, { mutators: { User } }) => {
|
||||
await User.cancelDeletion();
|
||||
},
|
||||
downloadUser: async (_, { id }, { mutators: { User } }) => ({
|
||||
archiveURL: await User.download(id),
|
||||
}),
|
||||
},
|
||||
User: {
|
||||
lastAccountDownload: (user, args, { user: currentUser }) => {
|
||||
|
||||
@@ -20,14 +20,15 @@ async function verifyDownloadToken(
|
||||
|
||||
// loadCommentsBatch will load a batch of the comments and write them to the
|
||||
// stream.
|
||||
async function loadCommentsBatch(ctx, csv, variables = {}) {
|
||||
async function loadCommentsBatch(ctx, csv, variables) {
|
||||
let result = await ctx.graphql(
|
||||
`
|
||||
query GetMyComments($cursor: Cursor) {
|
||||
me {
|
||||
query GetMyComments($userID: ID!, $cursor: Cursor) {
|
||||
user(id: $userID) {
|
||||
comments(query: {
|
||||
limit: 100,
|
||||
cursor: $cursor
|
||||
cursor: $cursor,
|
||||
statuses: null
|
||||
}) {
|
||||
hasNextPage
|
||||
endCursor
|
||||
@@ -50,7 +51,7 @@ async function loadCommentsBatch(ctx, csv, variables = {}) {
|
||||
throw result.errors;
|
||||
}
|
||||
|
||||
for (const comment of get(result, 'data.me.comments.nodes', [])) {
|
||||
for (const comment of get(result, 'data.user.comments.nodes', [])) {
|
||||
csv.write([
|
||||
comment.id,
|
||||
moment(comment.created_at).format('YYYY-MM-DD HH:mm:ss'),
|
||||
@@ -60,12 +61,12 @@ async function loadCommentsBatch(ctx, csv, variables = {}) {
|
||||
]);
|
||||
}
|
||||
|
||||
return pick(result.data.me.comments, ['hasNextPage', 'endCursor']);
|
||||
return pick(get(result, 'data.user.comments'), ['hasNextPage', 'endCursor']);
|
||||
}
|
||||
|
||||
// loadComments will load batches of the comments and write them to the csv
|
||||
// stream. Once the comments have finished writing, it will close the stream.
|
||||
async function loadComments(ctx, archive, latestContentDate) {
|
||||
async function loadComments(ctx, userID, archive, latestContentDate) {
|
||||
// Create all the csv writers that'll write the data to the archive.
|
||||
const csv = stringify();
|
||||
|
||||
@@ -78,12 +79,14 @@ async function loadComments(ctx, archive, latestContentDate) {
|
||||
// from the token.
|
||||
let connection = await loadCommentsBatch(ctx, csv, {
|
||||
cursor: latestContentDate,
|
||||
userID,
|
||||
});
|
||||
|
||||
// As long as there's more comments, keep paginating.
|
||||
while (connection.hasNextPage) {
|
||||
connection = await loadCommentsBatch(ctx, csv, {
|
||||
cursor: connection.endCursor,
|
||||
userID,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,11 +101,21 @@ module.exports = router => {
|
||||
|
||||
// /api/v1/account/download will send back a zipped archive of the users
|
||||
// account.
|
||||
router.post(
|
||||
router.all(
|
||||
'/api/v1/account/download',
|
||||
express.urlencoded({ extended: false }),
|
||||
async (req, res, next) => {
|
||||
const { token = null, check = false } = req.body;
|
||||
let { token = null, check = false } = req.body;
|
||||
|
||||
if (!token) {
|
||||
// If the token wasn't found in the body, then we should check the query
|
||||
// to see if it was passed that way.
|
||||
token = req.query.token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
if (check) {
|
||||
// This request is checking to see if the token is valid.
|
||||
@@ -120,7 +133,7 @@ module.exports = router => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { connectors: { services: { Users } } } = req.context;
|
||||
const { connectors: { graph: { Context }, errors } } = req.context;
|
||||
|
||||
try {
|
||||
// Pull the userID and the date that the token was issued out of the
|
||||
@@ -130,25 +143,31 @@ module.exports = router => {
|
||||
token
|
||||
);
|
||||
|
||||
// Create a system context used to get all comments for that user.
|
||||
const ctx = Context.forSystem();
|
||||
|
||||
// Get the current user's username. We need it for the generated filenames.
|
||||
const result = await ctx.graphql(
|
||||
`query GetUser($userID: ID!) {
|
||||
user(id: $userID) { username }
|
||||
}`,
|
||||
{ userID }
|
||||
);
|
||||
if (result.errors) {
|
||||
throw result.errors;
|
||||
}
|
||||
|
||||
const user = get(result, 'data.user');
|
||||
if (!user) {
|
||||
throw new errors.ErrNotFound();
|
||||
}
|
||||
|
||||
// Unpack the date that the token was issued, and use it as a source for the
|
||||
// earliest comment we should include in the download.
|
||||
const latestContentDate = new Date(iat * 1000);
|
||||
|
||||
// Grab the user that we're generating the export from. We'll use it to
|
||||
// create a new context.
|
||||
const user = await Users.findById(userID);
|
||||
|
||||
// Base a new context off of the new user.
|
||||
const ctx = req.context.masqueradeAs(user);
|
||||
|
||||
// Get the current user's username. We need it for the generated filenames.
|
||||
const result = await ctx.graphql('{ me { username } }');
|
||||
if (result.errors) {
|
||||
throw result.errors;
|
||||
}
|
||||
const username = get(result, 'data.me.username');
|
||||
|
||||
// Generate the filename of the file that the user will download.
|
||||
const username = get(user, 'username');
|
||||
const filename = `talk-${kebabCase(username)}-${kebabCase(
|
||||
moment(latestContentDate).format('YYYY-MM-DD HH:mm:ss')
|
||||
)}.zip`;
|
||||
@@ -167,7 +186,7 @@ module.exports = router => {
|
||||
archive.pipe(res);
|
||||
|
||||
// Load the comments csv up with the user's comments.
|
||||
await loadComments(ctx, archive, latestContentDate);
|
||||
await loadComments(ctx, userID, archive, latestContentDate);
|
||||
|
||||
// Mark the end of adding files, no more files can be added after this. Once
|
||||
// all the stream readers have finished writing, and have closed, the
|
||||
|
||||
@@ -41,6 +41,18 @@ type CancelAccountDeletionResponse implements Response {
|
||||
errors: [UserError!]
|
||||
}
|
||||
|
||||
# DownloadUserResponse contaisn the account download archiveURL that can be used
|
||||
# to directly download a zip file containing the user data.
|
||||
type DownloadUserResponse implements Response {
|
||||
|
||||
# archiveURL is the link that can be used within the next 1 hour to download a
|
||||
# users archive.
|
||||
archiveURL: String
|
||||
|
||||
# An array of errors relating to the mutation that occurred.
|
||||
errors: [UserError!]
|
||||
}
|
||||
|
||||
type RootMutation {
|
||||
|
||||
# requestDownloadLink will request a download link be sent to the primary
|
||||
@@ -54,4 +66,8 @@ type RootMutation {
|
||||
# cancelAccountDeletion will cancel a pending account deletion that was
|
||||
# previously scheduled.
|
||||
cancelAccountDeletion: CancelAccountDeletionResponse
|
||||
|
||||
# downloadUser will provide an account download for the indicated User. This
|
||||
# mutation requires the ADMIN role.
|
||||
downloadUser(id: ID!): DownloadUserResponse
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
en:
|
||||
download_landing:
|
||||
download_your_account: "Download Your Comment History"
|
||||
download_details: "Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma seperated value (or .csv) file that you can easily import into your favorite spreadsheet application."
|
||||
download_details: "Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma separated value (or .csv) file that you can easily import into your favorite spreadsheet application."
|
||||
all_information_included: "For each of your comments the following information is included:"
|
||||
information_included:
|
||||
date: "When you wrote the comment"
|
||||
|
||||
@@ -16,6 +16,9 @@ Enables secure rich text support server-side.
|
||||
Add `"talk-plugin-rich-text"` to the `plugins.json` in your Talk installation.
|
||||
This plugin provides a server and a client side implementation.
|
||||
|
||||
###### Note: Possible plugin conflict
|
||||
The plugin `talk-plugin-comment-content` will prevent this plugin from rendering comments with rich text styling and is not needed if this plugin is enabled.
|
||||
|
||||
## Server implementation
|
||||
|
||||
### How does this work?
|
||||
@@ -43,11 +46,11 @@ Settings for highlighting links. These will only apply if `higlightLinks` is set
|
||||
|
||||
#### `dompurify`
|
||||
|
||||
Rules to sanitize html input. We use [DOMPurify] (https://github.com/cure53/DOMPurify) to prevent web attacks and XSS. Here is the complete list of [settings] (https://github.com/cure53/DOMPurify)
|
||||
Rules to sanitize html input. We use [DOMPurify](https://github.com/cure53/DOMPurify) to prevent web attacks and XSS. Here is the complete list of [settings](https://github.com/cure53/DOMPurify)
|
||||
|
||||
#### `jsdom`
|
||||
|
||||
In order to run html in the server we need [jsdom](https://github.com/jsdom/jsdom). Usually you wouldn’t need to modify this settings.
|
||||
In order to run html in the server we need [jsdom](https://github.com/jsdom/jsdom). Usually you wouldn’t need to modify this settings.
|
||||
|
||||
## Client implementation
|
||||
|
||||
@@ -58,10 +61,10 @@ This plugin contains 2 important components:
|
||||
- The Editor (`./components/Editor.js`)
|
||||
- The Comment Content Renderer (`./components/CommentContent.js`)
|
||||
|
||||
The editor component utilizes the [contentEditable](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content) and execCommand API.
|
||||
The editor component utilizes the [contentEditable](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content) and execCommand API.
|
||||
|
||||
If you check our `index.js` you will notice that we inject this editor in the
|
||||
`commentBox` slot. We do this to replace the core comment box with this one.
|
||||
`commentBox` slot. We do this to replace the core comment box with this one.
|
||||
|
||||
Now, in order to render the new styled comments we need a comment renderer. For
|
||||
this task we will have to replace our core comment renderer by using the
|
||||
|
||||
@@ -12,7 +12,7 @@ de:
|
||||
label: Beliebteste zuerst
|
||||
es:
|
||||
talk-plugin-sort-most-liked:
|
||||
label: Most liked first
|
||||
label: Más valoradas primero
|
||||
fr:
|
||||
talk-plugin-sort-most-liked:
|
||||
label: Most liked first
|
||||
|
||||
@@ -12,7 +12,7 @@ de:
|
||||
label: Häufigste "Ich liebe es" zuerst
|
||||
es:
|
||||
talk-plugin-sort-most-loved:
|
||||
label: Most loved first
|
||||
label: Más amadas primero
|
||||
fr:
|
||||
talk-plugin-sort-most-loved:
|
||||
label: Most loved first
|
||||
|
||||
@@ -12,7 +12,7 @@ de:
|
||||
label: Häufigste Antworten zuerst
|
||||
es:
|
||||
talk-plugin-sort-most-replied:
|
||||
label: Most replied first
|
||||
label: Más respondidas primero
|
||||
fr:
|
||||
talk-plugin-sort-most-replied:
|
||||
label: Most replied first
|
||||
|
||||
@@ -12,7 +12,7 @@ de:
|
||||
label: Häufigste "Respektiert" zuerst
|
||||
es:
|
||||
talk-plugin-sort-most-respected:
|
||||
label: Most respected first
|
||||
label: Más respetadas primero
|
||||
fr:
|
||||
talk-plugin-sort-most-respected:
|
||||
label: Most respected first
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
en:
|
||||
talk-plugin-sort-most-upvoted:
|
||||
label: Most upvoted first
|
||||
es:
|
||||
talk-plugin-sort-oldest:
|
||||
label: Más votadas primero
|
||||
|
||||
@@ -12,7 +12,7 @@ de:
|
||||
label: Neueste zuerst
|
||||
es:
|
||||
talk-plugin-sort-newest:
|
||||
label: Newest first
|
||||
label: Más nuevas primero
|
||||
fr:
|
||||
talk-plugin-sort-newest:
|
||||
label: Newest first
|
||||
|
||||
@@ -12,7 +12,7 @@ de:
|
||||
label: Älteste zuerst
|
||||
es:
|
||||
talk-plugin-sort-oldest:
|
||||
label: Oldest first
|
||||
label: Más viejas primero
|
||||
fr:
|
||||
talk-plugin-sort-oldest:
|
||||
label: Oldest first
|
||||
|
||||
@@ -21,8 +21,8 @@ de:
|
||||
es:
|
||||
talk-plugin-viewing-options:
|
||||
viewing_options: "Opciones de visualización"
|
||||
sort: Sorting
|
||||
filter: Filtering
|
||||
sort: Ordenado por
|
||||
filter: Filtrado por
|
||||
fr:
|
||||
talk-plugin-viewing-options:
|
||||
viewing_options: "Viewing Options"
|
||||
|
||||
@@ -88,7 +88,7 @@ router.post('/password/reset', async (req, res, next) => {
|
||||
locals: {
|
||||
token,
|
||||
},
|
||||
subject: 'Password Reset',
|
||||
subject: res.locals.t('email.password_reset.subject'),
|
||||
email,
|
||||
});
|
||||
}
|
||||
@@ -109,20 +109,11 @@ router.put(
|
||||
async (req, res, next) => {
|
||||
const { token, password } = req.body;
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
return next(errors.ErrPasswordTooShort);
|
||||
}
|
||||
|
||||
try {
|
||||
let [user, redirect] = await UsersService.verifyPasswordResetToken(token);
|
||||
|
||||
// Change the users' password.
|
||||
await UsersService.changePassword(user.id, password);
|
||||
|
||||
const { redirect } = await UsersService.resetPassword(token, password);
|
||||
res.json({ redirect });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return next(errors.ErrNotAuthorized);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -66,6 +66,7 @@ if (CREATE_MONGO_INDEXES) {
|
||||
require('../models/action');
|
||||
require('../models/asset');
|
||||
require('../models/comment');
|
||||
require('../models/migration');
|
||||
require('../models/setting');
|
||||
require('../models/user');
|
||||
require('./migration');
|
||||
|
||||
+138
-50
@@ -1,4 +1,5 @@
|
||||
const uuid = require('uuid');
|
||||
const moment = require('moment');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const {
|
||||
ErrMaxRateLimit,
|
||||
@@ -132,7 +133,7 @@ class Users {
|
||||
locals: {
|
||||
body: message,
|
||||
},
|
||||
subject: 'Your account has been suspended',
|
||||
subject: 'Your account has been suspended', // TODO: replace with translation
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,57 +235,74 @@ class Users {
|
||||
return user;
|
||||
}
|
||||
|
||||
static async _setUsername(
|
||||
id,
|
||||
username,
|
||||
fromStatus,
|
||||
toStatus,
|
||||
assignedBy,
|
||||
resetAllowed = false
|
||||
) {
|
||||
static async setUsername(id, username, assignedBy) {
|
||||
try {
|
||||
const oldestEditTime = moment()
|
||||
.subtract(14, 'days')
|
||||
.toDate();
|
||||
|
||||
// A username can be set if:
|
||||
//
|
||||
// - The previous status was 'UNSET'
|
||||
// - The username has not been changed within the last 14 days.
|
||||
const query = {
|
||||
id,
|
||||
'status.username.status': fromStatus,
|
||||
};
|
||||
if (!resetAllowed) {
|
||||
query.username = { $ne: username };
|
||||
}
|
||||
|
||||
let user = await User.findOneAndUpdate(
|
||||
query,
|
||||
{
|
||||
$set: {
|
||||
username,
|
||||
lowercaseUsername: username.toLowerCase(),
|
||||
'status.username.status': toStatus,
|
||||
$or: [
|
||||
{
|
||||
'status.username.status': 'UNSET',
|
||||
},
|
||||
$push: {
|
||||
'status.username.history': {
|
||||
status: toStatus,
|
||||
assigned_by: assignedBy,
|
||||
created_at: Date.now(),
|
||||
},
|
||||
{
|
||||
'status.username.status': { $in: ['APPROVED', 'SET'] },
|
||||
$or: [
|
||||
{
|
||||
'status.username.history.created_at': {
|
||||
$lte: oldestEditTime,
|
||||
},
|
||||
},
|
||||
{
|
||||
'status.username.history': [],
|
||||
},
|
||||
{
|
||||
'status.username.history': { $exists: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const update = {
|
||||
$set: {
|
||||
username,
|
||||
lowercaseUsername: username.toLowerCase(),
|
||||
'status.username.status': 'SET',
|
||||
},
|
||||
$push: {
|
||||
'status.username.history': {
|
||||
status: 'SET',
|
||||
assigned_by: assignedBy,
|
||||
created_at: Date.now(),
|
||||
},
|
||||
},
|
||||
{
|
||||
new: true,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
let user = await User.findOneAndUpdate(query, update, {
|
||||
new: true,
|
||||
});
|
||||
if (!user) {
|
||||
user = await Users.findById(id);
|
||||
if (user === null) {
|
||||
throw new ErrNotFound();
|
||||
}
|
||||
|
||||
if (user.status.username.status !== fromStatus) {
|
||||
if (
|
||||
!['UNSET', 'APPROVED', 'SET'].includes(user.status.username.status) ||
|
||||
user.status.username.history.some(({ created_at }) =>
|
||||
moment(created_at).isAfter(oldestEditTime)
|
||||
)
|
||||
) {
|
||||
throw new ErrPermissionUpdateUsername();
|
||||
}
|
||||
|
||||
if (!resetAllowed && user.username === username) {
|
||||
throw new ErrSameUsernameProvided();
|
||||
}
|
||||
|
||||
throw new Error('edit username failed for an unexpected reason');
|
||||
}
|
||||
|
||||
@@ -298,12 +316,57 @@ class Users {
|
||||
}
|
||||
}
|
||||
|
||||
static async setUsername(id, username, assignedBy) {
|
||||
return Users._setUsername(id, username, 'UNSET', 'SET', assignedBy, true);
|
||||
}
|
||||
|
||||
static async changeUsername(id, username, assignedBy) {
|
||||
return Users._setUsername(id, username, 'REJECTED', 'CHANGED', assignedBy);
|
||||
try {
|
||||
const query = {
|
||||
id,
|
||||
username: { $ne: username },
|
||||
'status.username.status': 'REJECTED',
|
||||
};
|
||||
|
||||
const update = {
|
||||
$set: {
|
||||
username,
|
||||
lowercaseUsername: username.toLowerCase(),
|
||||
'status.username.status': 'CHANGED',
|
||||
},
|
||||
$push: {
|
||||
'status.username.history': {
|
||||
status: 'CHANGED',
|
||||
assigned_by: assignedBy,
|
||||
created_at: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let user = await User.findOneAndUpdate(query, update, {
|
||||
new: true,
|
||||
});
|
||||
if (!user) {
|
||||
user = await Users.findById(id);
|
||||
if (user === null) {
|
||||
throw new ErrNotFound();
|
||||
}
|
||||
|
||||
if (user.status.username.status !== 'REJECTED') {
|
||||
throw new ErrPermissionUpdateUsername();
|
||||
}
|
||||
|
||||
if (user.username === username) {
|
||||
throw new ErrSameUsernameProvided();
|
||||
}
|
||||
|
||||
throw new Error('edit username failed for an unexpected reason');
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (err) {
|
||||
if (err.code === 11000) {
|
||||
throw new ErrUsernameTaken();
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -490,6 +553,10 @@ class Users {
|
||||
}
|
||||
|
||||
static async changePassword(id, password) {
|
||||
if (!password || password.length < 8) {
|
||||
throw new ErrPasswordTooShort();
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
|
||||
return User.update(
|
||||
@@ -725,18 +792,13 @@ class Users {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a jwt and returns the associated user. Throws an error when the
|
||||
* token isn't valid.
|
||||
*
|
||||
* @param {String} token the JSON Web Token to verify
|
||||
*/
|
||||
// TODO: update doc
|
||||
static async verifyPasswordResetToken(token) {
|
||||
if (!token) {
|
||||
throw new Error('cannot verify an empty token');
|
||||
}
|
||||
|
||||
const { userId, loc, version } = await Users.verifyToken(token, {
|
||||
const { userId, loc: redirect, version } = await Users.verifyToken(token, {
|
||||
subject: PASSWORD_RESET_JWT_SUBJECT,
|
||||
});
|
||||
|
||||
@@ -746,7 +808,33 @@ class Users {
|
||||
throw new Error('password reset token has expired');
|
||||
}
|
||||
|
||||
return [user, loc];
|
||||
return { user, redirect, version };
|
||||
}
|
||||
|
||||
// TODO: update doc
|
||||
static async resetPassword(token, password) {
|
||||
const { user, redirect, version } = await this.verifyPasswordResetToken(
|
||||
token
|
||||
);
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
throw new ErrPasswordTooShort();
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
|
||||
// Update the user's password.
|
||||
await User.update(
|
||||
{ id: user.id, __v: version },
|
||||
{
|
||||
$inc: { __v: 1 },
|
||||
$set: {
|
||||
password: hashedPassword,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return { user, redirect };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@ const UsersService = require('../../../services/users');
|
||||
const SettingsService = require('../../../services/settings');
|
||||
const mailer = require('../../../services/mailer');
|
||||
const Context = require('../../../graph/context');
|
||||
const timekeeper = require('timekeeper');
|
||||
const moment = require('moment');
|
||||
|
||||
const chai = require('chai');
|
||||
chai.use(require('chai-as-promised'));
|
||||
@@ -302,6 +304,62 @@ describe('services.UsersService', () => {
|
||||
await UsersService[func](user.id, user.username);
|
||||
}
|
||||
});
|
||||
|
||||
if (func === 'setUsername') {
|
||||
it('should let a user set their username from UNSET', async () => {
|
||||
const user = mockUsers[0];
|
||||
|
||||
// Set the user to the desired status.
|
||||
await UsersService.setUsernameStatus(user.id, 'UNSET');
|
||||
await UsersService.setUsername(user.id, 'new_username', null);
|
||||
});
|
||||
|
||||
describe('time based', () => {
|
||||
afterEach(() => {
|
||||
timekeeper.reset();
|
||||
});
|
||||
|
||||
['SET', 'APPROVED'].forEach(status => {
|
||||
it(`should not allow users to change their username if it was changed within 14 of today from ${status}`, async () => {
|
||||
const user = mockUsers[0];
|
||||
|
||||
// Set the user to the desired status.
|
||||
await UsersService.setUsernameStatus(user.id, status);
|
||||
|
||||
timekeeper.travel(
|
||||
moment()
|
||||
.add(5, 'days')
|
||||
.toDate()
|
||||
);
|
||||
|
||||
try {
|
||||
await UsersService.setUsername(user.id, 'new_username', null);
|
||||
throw new Error('edit was processed successfully');
|
||||
} catch (err) {
|
||||
expect(err).have.property(
|
||||
'translation_key',
|
||||
'EDIT_USERNAME_NOT_AUTHORIZED'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it(`allows users to change their username if it was changed 14 days before today from ${status}`, async () => {
|
||||
const user = mockUsers[0];
|
||||
|
||||
// Set the user to the desired status.
|
||||
await UsersService.setUsernameStatus(user.id, status);
|
||||
|
||||
timekeeper.travel(
|
||||
moment()
|
||||
.add(15, 'days')
|
||||
.toDate()
|
||||
);
|
||||
|
||||
await UsersService.setUsername(user.id, 'new_username', null);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -11,8 +11,8 @@
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.min.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('coral-admin/bundle.css') %>">
|
||||
<%- include partials/head %>
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('coral-admin/bundle.css') %>">
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||
<%- include ../partials/head %>
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('embed/stream/default.css') %>">
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('embed/stream/bundle.css') %>">
|
||||
<%- include ../partials/head %>
|
||||
</head>
|
||||
<body class="embed-stream-page">
|
||||
<div id="talk-embed-stream-container"></div>
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('coral-login/bundle.css') %>">
|
||||
<%- include partials/head %>
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('coral-login/bundle.css') %>">
|
||||
</head>
|
||||
<body>
|
||||
<div id="talk-login-container"></div>
|
||||
|
||||
Reference in New Issue
Block a user