0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Added basic member authentication

refs https://github.com/TryGhost/Team/issues/1664

- Added basic API handling
- Added member authentication
- Basic avatar with form
- Setup sentry
This commit is contained in:
Simon Backx 2022-07-05 10:34:52 +02:00
parent 0eeff657de
commit 710c265601
6 changed files with 269 additions and 7 deletions

View file

@ -1,10 +1,12 @@
import Form from './components/Form';
import CustomIFrame from './components/CustomIFrame';
import * as Sentry from '@sentry/react';
import React from 'react';
import ActionHandler from './actions';
import {createPopupNotification} from './utils/helpers';
import {createPopupNotification,isSentryEventAllowed} from './utils/helpers';
import AppContext from './AppContext';
import {hasMode} from './utils/check-mode';
import setupGhostApi from './utils/api';
import CommentsBox from './components/CommentsBox';
function SentryErrorBoundary({dsn, children}) {
if (dsn) {
@ -28,11 +30,41 @@ export default class App extends React.Component {
// Todo: this state is work in progress
this.state = {
action: 'init:running',
initStatus: 'running',
member: null,
popupNotification: null,
customSiteUrl: props.customSiteUrl
};
}
componentDidMount() {
this.initSetup();
}
/** Initialize comments setup on load, fetch data and setup state*/
async initSetup() {
try {
// Fetch data from API, links, preview, dev sources
const {site, member} = await this.fetchApiData();
const state = {
site,
member,
action: 'init:success',
initStatus: 'success'
};
this.setState(state);
} catch (e) {
/* eslint-disable no-console */
console.error(`[Comments] Failed to initialize:`, e);
/* eslint-enable no-console */
this.setState({
action: 'init:failed',
initStatus: 'failed'
});
}
}
/** Handle actions from across App and update App state */
async dispatchAction(action, data) {
clearTimeout(this.timeoutId);
@ -66,13 +98,59 @@ export default class App extends React.Component {
}
}
/** Fetch site and member session data with Ghost Apis */
async fetchApiData() {
const {siteUrl, customSiteUrl, apiUrl, apiKey} = this.props;
try {
this.GhostApi = this.props.api || setupGhostApi({siteUrl, apiUrl, apiKey});
const {site, member} = await this.GhostApi.init();
this.setupSentry({site});
return {site, member};
} catch (e) {
if (hasMode(['dev', 'test'], {customSiteUrl})) {
return {};
}
throw e;
}
}
/** Setup Sentry */
setupSentry({site}) {
if (hasMode(['test'])) {
return null;
}
const {portal_sentry: portalSentry, portal_version: portalVersion, version: ghostVersion} = site;
const appVersion = process.env.REACT_APP_VERSION || portalVersion;
const releaseTag = `comments@${appVersion}|ghost@${ghostVersion}`;
if (portalSentry && portalSentry.dsn) {
Sentry.init({
dsn: portalSentry.dsn,
environment: portalSentry.env || 'development',
release: releaseTag,
beforeSend: (event) => {
if (isSentryEventAllowed({event})) {
return event;
}
return null;
},
allowUrls: [
/https?:\/\/((www)\.)?unpkg\.com\/@tryghost\/comments/
]
});
}
}
/**Get final App level context from App state*/
getContextFromState() {
const {action, popupNotification, customSiteUrl} = this.state;
const {action, popupNotification, customSiteUrl, member} = this.state;
return {
action,
popupNotification,
customSiteUrl,
member,
onAction: (_action, data) => this.dispatchAction(_action, data)
};
}
@ -83,6 +161,10 @@ export default class App extends React.Component {
}
render() {
if (this.state.initStatus !== 'success') {
return null;
}
const iFrameStyles = {
border: 'none',
width: '100%'
@ -92,7 +174,7 @@ export default class App extends React.Component {
<SentryErrorBoundary dsn={this.props.sentryDsn}>
<AppContext.Provider value={this.getContextFromState()}>
<CustomIFrame style={iFrameStyles}>
<Form />
<CommentsBox />
</CustomIFrame>
</AppContext.Provider>
</SentryErrorBoundary>

View file

@ -0,0 +1,23 @@
import React from 'react';
import AppContext from '../AppContext';
import NotSignedInBox from './NotSignedInBox';
import Form from './Form';
class CommentsBox extends React.Component {
static contextType = AppContext;
constructor(props) {
super(props);
this.state = {};
}
render() {
return (
<section>
{ this.context.member ? <Form /> : <NotSignedInBox /> }
</section>
);
}
}
export default CommentsBox;

View file

@ -38,12 +38,44 @@ class Form extends React.Component {
this.setState({message: event.target.value});
}
getInitials() {
if (!this.context.member) {
return '';
}
const parts = this.context.member.name.split(' ');
if (parts.length === 0) {
return '';
}
if (parts.length === 1) {
return parts[0].substring(0, 1);
}
return parts[0].substring(0, 1) + parts[parts.length - 1].substring(0, 1);
}
render() {
return (
<form onSubmit={this.submitForm} className="comment-form">
<figure className="avatar">
<span />
</figure>
<div style={{display: 'flex', flexDirection: 'row'}}>
<figure style={{position: 'relative', marginRight: '15px'}}>
<div style={{backgroundColor: 'black', width: '60px', height: '60px', borderRadius: '30px', color: 'white', textAlign: 'center', fontSize: '20px', fontWeight: 'bold', lineHeight: '60px'}}>
{ this.getInitials() }
</div>
{ this.context.member ? <img src={this.context.member.avatar_image} width="60" height="60" style={{position: 'absolute', left: '0', top: '0'}} alt="Avatar"/> : '' }
</figure>
<div>
<div style={{fontWeight: 'bold'}}>
{this.context.member ? this.context.member.name : ''}
</div>
<span>
Add a bio
</span>
</div>
</div>
<textarea className="w-full rounded-md border p-2" value={this.state.message} onChange={this.handleChange} placeholder="What are your thoughts?" />
<button type="submit" className="bg-black p-2 text-white rounded w-full mt-2">Comment</button>
</form>

View file

@ -0,0 +1,10 @@
function NotSignedInBox() {
return (
<section>
<h1>You are not signed in</h1>
<p>Log in to place a comment.</p>
</section>
);
}
export default NotSignedInBox;

View file

@ -0,0 +1,100 @@
import {transformApiSiteData} from './helpers';
function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
const apiPath = 'members/api';
function endpointFor({type, resource}) {
if (type === 'members') {
return `${siteUrl.replace(/\/$/, '')}/${apiPath}/${resource}/`;
}
}
function contentEndpointFor({resource, params = ''}) {
if (apiUrl && apiKey) {
return `${apiUrl.replace(/\/$/, '')}/${resource}/?key=${apiKey}&limit=all${params}`;
}
return '';
}
function makeRequest({url, method = 'GET', headers = {}, credentials = undefined, body = undefined}) {
const options = {
method,
headers,
credentials,
body
};
return fetch(url, options);
}
const api = {};
api.site = {
settings() {
const url = contentEndpointFor({resource: 'settings'});
return makeRequest({
url,
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}).then(function (res) {
if (res.ok) {
return res.json();
} else {
throw new Error('Failed to fetch site data');
}
});
}
};
api.member = {
identity() {
const url = endpointFor({type: 'members', resource: 'session'});
return makeRequest({
url,
credentials: 'same-origin'
}).then(function (res) {
if (!res.ok || res.status === 204) {
return null;
}
return res.text();
});
},
sessionData() {
const url = endpointFor({type: 'members', resource: 'member'});
return makeRequest({
url,
credentials: 'same-origin'
}).then(function (res) {
if (!res.ok || res.status === 204) {
return null;
}
return res.json();
});
}
};
api.init = async () => {
let [member] = await Promise.all([
api.member.sessionData()
]);
let site = {};
let settings = {};
try {
settings = await api.site.settings();
site = {
...settings
};
} catch (e) {
// Ignore
}
site = transformApiSiteData({site});
return {site, member};
};
return api;
}
export default setupGhostApi;

View file

@ -14,3 +14,18 @@ export const createPopupNotification = ({type, status, autoHide, duration = 2600
count
};
};
export function transformApiSiteData({site}) {
if (!site) {
return null;
}
return site;
}
export function isSentryEventAllowed({event: sentryEvent}) {
const frames = sentryEvent?.exception?.values?.[0]?.stacktrace?.frames || [];
const fileNames = frames.map(frame => frame.filename).filter(filename => !!filename);
const lastFileName = fileNames[fileNames.length - 1] || '';
return lastFileName.includes('@tryghost/comments');
}