diff --git a/web/app/common/api.js b/web/app/common/api.js
index d79faf2e..8534874b 100644
--- a/web/app/common/api.js
+++ b/web/app/common/api.js
@@ -69,6 +69,13 @@ export const getUser = () =>
withCredentials: true,
});
+/* GDPR */
+
+export const deleteMe = () =>
+ fetcher.post({
+ url: `/deleteme?site=${siteId}`,
+ });
+
/* admin */
export const pinComment = ({ id, url }) =>
fetcher.put({
diff --git a/web/app/components/auth-panel/__column/auth-panel__column.scss b/web/app/components/auth-panel/__column/auth-panel__column.scss
index fa430522..8706f23b 100644
--- a/web/app/components/auth-panel/__column/auth-panel__column.scss
+++ b/web/app/components/auth-panel/__column/auth-panel__column.scss
@@ -1,8 +1,6 @@
.auth-panel__column {
&:nth-child(1) {
- overflow: hidden;
font-weight: 700;
- text-overflow: ellipsis;
}
&:nth-child(2) {
diff --git a/web/app/components/auth-panel/__sign-out/auth-panel__sign-out.scss b/web/app/components/auth-panel/__sign-out/auth-panel__sign-out.scss
new file mode 100644
index 00000000..17f5627c
--- /dev/null
+++ b/web/app/components/auth-panel/__sign-out/auth-panel__sign-out.scss
@@ -0,0 +1,3 @@
+.auth-panel__sign-out {
+ margin-left: 5px;
+}
diff --git a/web/app/components/auth-panel/__tests__/auth-panel.test.js b/web/app/components/auth-panel/__tests__/auth-panel.test.js
index ce8f1bb7..8e55f00a 100644
--- a/web/app/components/auth-panel/__tests__/auth-panel.test.js
+++ b/web/app/components/auth-panel/__tests__/auth-panel.test.js
@@ -48,7 +48,7 @@ describe('', () => {
const userInfo = authPanelColumn[0];
- expect(userInfo.textContent).toEqual(expect.stringContaining('You signed in as John. Sign out?'));
+ expect(userInfo.textContent).toEqual(expect.stringContaining('You signed in as John'));
});
});
describe('For admin user', () => {
diff --git a/web/app/components/auth-panel/__user-id/auth-panel__user-id.jsx b/web/app/components/auth-panel/__user-id/auth-panel__user-id.jsx
new file mode 100644
index 00000000..f04859f5
--- /dev/null
+++ b/web/app/components/auth-panel/__user-id/auth-panel__user-id.jsx
@@ -0,0 +1,8 @@
+/** @jsx h */
+import { h } from 'preact';
+
+export default ({ id }) => (
+
+ {id}
+
+);
diff --git a/web/app/components/auth-panel/__user-id/auth-panel__user-id.scss b/web/app/components/auth-panel/__user-id/auth-panel__user-id.scss
index ed32e370..b33fd069 100644
--- a/web/app/components/auth-panel/__user-id/auth-panel__user-id.scss
+++ b/web/app/components/auth-panel/__user-id/auth-panel__user-id.scss
@@ -1,3 +1,5 @@
.auth-panel__user-id {
+ overflow: hidden;
+ text-overflow: ellipsis;
color: #888;
}
diff --git a/web/app/components/auth-panel/__username/auth-panel__username.scss b/web/app/components/auth-panel/__username/auth-panel__username.scss
deleted file mode 100644
index 0c53cf67..00000000
--- a/web/app/components/auth-panel/__username/auth-panel__username.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-.auth-panel__username {
- cursor: default;
-}
diff --git a/web/app/components/auth-panel/auth-panel.jsx b/web/app/components/auth-panel/auth-panel.jsx
index a2560f8c..a3657625 100644
--- a/web/app/components/auth-panel/auth-panel.jsx
+++ b/web/app/components/auth-panel/auth-panel.jsx
@@ -1,14 +1,17 @@
/** @jsx h */
import { h, Component } from 'preact';
+import UserId from './__user-id/auth-panel__user-id';
+import Dropdown, { DropdownItem } from 'components/dropdown';
+import Button from 'components/button';
import { PROVIDER_NAMES } from 'common/constants';
+import { requestDeletion } from 'utils/email';
import { getHandleClickProps } from 'common/accessibility';
export default class AuthPanel extends Component {
constructor(props) {
super(props);
- this.toggleUserId = this.toggleUserId.bind(this);
this.toggleBlockedVisibility = this.toggleBlockedVisibility.bind(this);
this.toggleCommentsAvailability = this.toggleCommentsAvailability.bind(this);
this.onSortChange = this.onSortChange.bind(this);
@@ -20,10 +23,6 @@ export default class AuthPanel extends Component {
}
}
- toggleUserId() {
- this.setState({ isUserIdVisible: !this.state.isUserIdVisible });
- }
-
toggleBlockedVisibility() {
if (!this.state.isBlockedVisible) {
if (this.props.onBlockedUsersShow) this.props.onBlockedUsersShow();
@@ -44,7 +43,12 @@ export default class AuthPanel extends Component {
}
}
- render(props, { isUserIdVisible, isBlockedVisible }) {
+ getUserTitle() {
+ const { user } = this.props;
+ return {user.name};
+ }
+
+ render(props, { isBlockedVisible }) {
const { user, providers = [], sort, isCommentsDisabled } = props;
const sortArray = getSortArray(sort);
@@ -55,13 +59,20 @@ export default class AuthPanel extends Component {
{loggedIn && (
You signed in as{' '}
-
- {user.name}
-
- {isUserIdVisible && ({user.id})}.{' '}
-
+
+
+
+
+
+
+
+
+ {' '}
+
+
)}
diff --git a/web/app/components/auth-panel/index.js b/web/app/components/auth-panel/index.js
index 6482d63b..5a8a50a1 100644
--- a/web/app/components/auth-panel/index.js
+++ b/web/app/components/auth-panel/index.js
@@ -7,7 +7,8 @@ require('./__pseudo-link/auth-panel__pseudo-link.scss');
require('./__select/auth-panel__select.scss');
require('./__select-label/auth-panel__select-label.scss');
require('./__sort/auth-panel__sort.scss');
-require('./__username/auth-panel__username.scss');
+
require('./__user-id/auth-panel__user-id.scss');
+require('./__sign-out/auth-panel__sign-out.scss');
require('./_logged-in/auth-panel_logged-in.scss');
diff --git a/web/app/components/button/_focused/button_focused.scss b/web/app/components/button/_focused/button_focused.scss
new file mode 100644
index 00000000..098f02aa
--- /dev/null
+++ b/web/app/components/button/_focused/button_focused.scss
@@ -0,0 +1,3 @@
+.button_focused:not(.button_clicked) {
+ outline-width: 5px;
+}
diff --git a/web/app/components/button/_kind/button_kind.scss b/web/app/components/button/_kind/button_kind.scss
new file mode 100644
index 00000000..ecd0a88e
--- /dev/null
+++ b/web/app/components/button/_kind/button_kind.scss
@@ -0,0 +1,19 @@
+.button_kind_text {
+ border: none;
+ background: transparent;
+ padding: 0;
+}
+
+.button_kind_link {
+ border: none;
+ background: transparent;
+ padding: 0;
+
+ font-weight: 700;
+ white-space: nowrap;
+ color: #0aa;
+
+ &:hover {
+ color: #06c5c5;
+ }
+}
diff --git a/web/app/components/button/button.jsx b/web/app/components/button/button.jsx
new file mode 100644
index 00000000..e1f9fab9
--- /dev/null
+++ b/web/app/components/button/button.jsx
@@ -0,0 +1,69 @@
+/** @jsx h */
+import { Component, h } from 'preact';
+import noop from '../../utils/noop';
+
+export default class Button extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isClicked: false,
+ isFocused: false,
+ };
+
+ this.onMouseDown = this.onMouseDown.bind(this);
+ this.onFocus = this.onFocus.bind(this);
+ this.onBlur = this.onBlur.bind(this);
+ }
+
+ onMouseDown() {
+ this.setState({
+ isClicked: true,
+ });
+ }
+
+ onClick(e) {
+ this.props.onClick(e);
+ }
+
+ onBlur(e) {
+ this.setState({
+ isClicked: false,
+ isFocused: false,
+ });
+
+ this.props.onBlur(e);
+ }
+
+ onFocus(e) {
+ this.setState({
+ isFocused: true,
+ });
+
+ this.props.onFocus(e);
+ }
+
+ render(props, state) {
+ const { children, mix, mods, ...rest } = props;
+ const { isClicked, isFocused } = state;
+
+ return (
+
+ );
+ }
+}
+
+Button.defaultProps = {
+ type: 'button',
+ onClick: noop,
+ onBlur: noop,
+ onFocus: noop,
+};
diff --git a/web/app/components/button/button.scss b/web/app/components/button/button.scss
new file mode 100644
index 00000000..cba7381d
--- /dev/null
+++ b/web/app/components/button/button.scss
@@ -0,0 +1,6 @@
+.button {
+ cursor: pointer;
+ outline-width: 0;
+ font-size: inherit;
+ font-family: inherit;
+}
diff --git a/web/app/components/button/index.js b/web/app/components/button/index.js
new file mode 100644
index 00000000..ff37104a
--- /dev/null
+++ b/web/app/components/button/index.js
@@ -0,0 +1,5 @@
+export { default } from './button';
+
+require('./button.scss');
+require('./_kind/button_kind.scss');
+require('./_focused/button_focused.scss');
diff --git a/web/app/components/dropdown/__content/dropdown__content.scss b/web/app/components/dropdown/__content/dropdown__content.scss
new file mode 100644
index 00000000..590bcb20
--- /dev/null
+++ b/web/app/components/dropdown/__content/dropdown__content.scss
@@ -0,0 +1,14 @@
+.dropdown__content {
+ position: absolute;
+ z-index: 20;
+ outline-width: 0;
+ display: none;
+ top: 100%;
+ left: 50%;
+ transform: translate(-50%, 5px);
+ width: 170px;
+ background-color: #fff;
+ border: 2px solid #259c9a;
+ border-radius: 3px;
+ padding: 0 0 5px;
+}
diff --git a/web/app/components/dropdown/__item/dropdown__item.jsx b/web/app/components/dropdown/__item/dropdown__item.jsx
new file mode 100644
index 00000000..5abd8331
--- /dev/null
+++ b/web/app/components/dropdown/__item/dropdown__item.jsx
@@ -0,0 +1,8 @@
+/** @jsx h */
+import { h } from 'preact';
+
+export default function DropdownItem(props) {
+ const { children, separator = false, mix, mods } = props;
+
+ return {children}
;
+}
diff --git a/web/app/components/dropdown/__item/dropdown__item.scss b/web/app/components/dropdown/__item/dropdown__item.scss
new file mode 100644
index 00000000..87787660
--- /dev/null
+++ b/web/app/components/dropdown/__item/dropdown__item.scss
@@ -0,0 +1,14 @@
+.dropdown__item {
+ a, button {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: 5px 15px;
+ }
+
+ &_separator {
+ border-bottom: 1px solid #259c9a;
+ margin-bottom: 5px;
+ padding: 5px 15px;
+ }
+}
diff --git a/web/app/components/dropdown/__items/dropdown__items.scss b/web/app/components/dropdown/__items/dropdown__items.scss
new file mode 100644
index 00000000..8e2ca0cd
--- /dev/null
+++ b/web/app/components/dropdown/__items/dropdown__items.scss
@@ -0,0 +1,3 @@
+.dropdown__items {
+ padding: 5px 0;
+}
diff --git a/web/app/components/dropdown/__title/dropdown__title.scss b/web/app/components/dropdown/__title/dropdown__title.scss
new file mode 100644
index 00000000..aaa7b97d
--- /dev/null
+++ b/web/app/components/dropdown/__title/dropdown__title.scss
@@ -0,0 +1,12 @@
+.dropdown__title {
+ border: none;
+ background: none;
+ font-weight: bold;
+ padding: 0;
+ margin: 0;
+
+ &::after {
+ content: '\25BE';
+ margin-left: 2px;
+ }
+}
diff --git a/web/app/components/dropdown/_active/dropdown_active.scss b/web/app/components/dropdown/_active/dropdown_active.scss
new file mode 100644
index 00000000..5a3e604b
--- /dev/null
+++ b/web/app/components/dropdown/_active/dropdown_active.scss
@@ -0,0 +1,5 @@
+.dropdown_active {
+ .dropdown__content {
+ display: block;
+ }
+}
diff --git a/web/app/components/dropdown/dropdown.jsx b/web/app/components/dropdown/dropdown.jsx
new file mode 100644
index 00000000..439d11df
--- /dev/null
+++ b/web/app/components/dropdown/dropdown.jsx
@@ -0,0 +1,76 @@
+/** @jsx h */
+import { Component, h } from 'preact';
+
+import Button from 'components/button';
+
+export default class Dropdown extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isActive: props.isActive || false,
+ };
+
+ this.onTitleClick = this.onTitleClick.bind(this);
+ this.onOutsideClick = this.onOutsideClick.bind(this);
+ }
+
+ onTitleClick() {
+ this.setState({
+ isActive: !this.state.isActive,
+ });
+
+ if (this.props.onTitleClick) {
+ this.props.onTitleClick();
+ }
+ }
+
+ onOutsideClick(e) {
+ if (!this.rootNode.contains(e.target)) {
+ if (this.state.isActive) {
+ this.setState({
+ isActive: false,
+ });
+ }
+ }
+ }
+
+ componentDidMount() {
+ document.addEventListener('click', this.onOutsideClick);
+
+ if (parent) {
+ parent.document.addEventListener('click', this.onOutsideClick);
+ }
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('click', this.onOutsideClick);
+
+ if (parent) {
+ parent.document.removeEventListener('click', this.onOutsideClick);
+ }
+ }
+
+ render(props, { isActive }) {
+ const { title, heading, children, mix, mods } = props;
+
+ return (
+ (this.rootNode = r)}>
+
+
+
+ {heading &&
{heading}
}
+
{children}
+
+
+ );
+ }
+}
diff --git a/web/app/components/dropdown/dropdown.scss b/web/app/components/dropdown/dropdown.scss
new file mode 100644
index 00000000..7969bc0d
--- /dev/null
+++ b/web/app/components/dropdown/dropdown.scss
@@ -0,0 +1,4 @@
+.dropdown {
+ display: inline-block;
+ position: relative;
+}
diff --git a/web/app/components/dropdown/index.js b/web/app/components/dropdown/index.js
new file mode 100644
index 00000000..d9f285ee
--- /dev/null
+++ b/web/app/components/dropdown/index.js
@@ -0,0 +1,10 @@
+export { default } from './dropdown';
+export { default as DropdownItem } from './__item/dropdown__item';
+
+require('./dropdown.scss');
+require('./_active/dropdown_active.scss');
+
+require('./__item/dropdown__item.scss');
+require('./__items/dropdown__items.scss');
+require('./__title/dropdown__title.scss');
+require('./__content/dropdown__content.scss');
diff --git a/web/app/testUtils/mockStyles.js b/web/app/testUtils/mockStyles.js
new file mode 100644
index 00000000..f053ebf7
--- /dev/null
+++ b/web/app/testUtils/mockStyles.js
@@ -0,0 +1 @@
+module.exports = {};
diff --git a/web/app/utils/email.js b/web/app/utils/email.js
new file mode 100644
index 00000000..a980c834
--- /dev/null
+++ b/web/app/utils/email.js
@@ -0,0 +1,35 @@
+import store from '../common/store';
+import { siteId } from '../common/settings';
+import { deleteMe } from '../common/api';
+
+// The right line breaks code in the body of inline email
+// should be not just %0A, but %0D%0A
+// see: https://www.ietf.org/rfc/rfc2368.txt
+const LINE_BREAK_CODE = '%0D%0A';
+
+export function getDeleteInformationMessage(userId, siteId, link) {
+ const subject = encodeURIComponent("Request to delete user's information");
+ const message = encodeURIComponent(`Request to delete all information about ${userId} from remark42 on ${siteId}
+
+[you can provide the reason for removal request, optional]
+
+=== DO NOT REMOVE THE TEXT BELOW THIS LINE ===
+
+site: ${siteId}
+user: ${userId}
+link: ${link}
+`).replace('%0A', LINE_BREAK_CODE);
+
+ return {
+ subject,
+ message,
+ };
+}
+
+export function requestDeletion() {
+ deleteMe().then(data => {
+ const email = store.get('config').admin_email;
+ const { subject, message } = getDeleteInformationMessage(data.user_id, siteId, data.link);
+ window.open(`mailto:${email}?subject=${subject}&body=${message}`);
+ });
+}
diff --git a/web/app/utils/noop.js b/web/app/utils/noop.js
new file mode 100644
index 00000000..ca6a7447
--- /dev/null
+++ b/web/app/utils/noop.js
@@ -0,0 +1 @@
+export default function noop() {}
diff --git a/web/package.json b/web/package.json
index d8c261b0..a49abbea 100644
--- a/web/package.json
+++ b/web/package.json
@@ -83,7 +83,10 @@
],
"testMatch": [
"/**/*.test.js"
- ]
+ ],
+ "moduleNameMapper": {
+ "\\.scss$": "/app/testUtils/mockStyles.js"
+ }
},
"engines": {
"node": ">=8"