diff --git a/.bitmap b/.bitmap new file mode 100644 index 000000000..f34da0763 --- /dev/null +++ b/.bitmap @@ -0,0 +1,89 @@ +/* THIS IS A BIT-AUTO-GENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. */ + +{ + "lumieducation.lumi/auth@1.0.5": { + "files": [ + { + "relativePath": "client/src/views/components/Auth.tsx", + "test": false, + "name": "Auth.tsx" + } + ], + "mainFile": "client/src/views/components/Auth.tsx", + "origin": "AUTHORED", + "exported": true + }, + "lumieducation.lumi/h5p-avatar@1.0.4": { + "files": [ + { + "relativePath": "client/src/views/components/H5PAvatar.tsx", + "test": false, + "name": "H5PAvatar.tsx" + } + ], + "mainFile": "client/src/views/components/H5PAvatar.tsx", + "origin": "AUTHORED", + "exported": true + }, + "lumieducation.lumi/logo@1.0.5": { + "files": [ + { + "relativePath": "client/src/views/components/Logo.tsx", + "test": false, + "name": "Logo.tsx" + } + ], + "mainFile": "client/src/views/components/Logo.tsx", + "origin": "AUTHORED", + "exported": true + }, + "lumieducation.lumi/run-link@1.0.5": { + "files": [ + { + "relativePath": "client/src/views/components/RunLink.tsx", + "test": false, + "name": "RunLink.tsx" + } + ], + "mainFile": "client/src/views/components/RunLink.tsx", + "origin": "AUTHORED", + "exported": true + }, + "lumieducation.lumi/run-list@1.0.6": { + "files": [ + { + "relativePath": "client/src/views/components/RunList.tsx", + "test": false, + "name": "RunList.tsx" + } + ], + "mainFile": "client/src/views/components/RunList.tsx", + "origin": "AUTHORED", + "exported": true + }, + "lumieducation.lumi/run-setup-dialog@1.0.5": { + "files": [ + { + "relativePath": "client/src/views/components/RunSetupDialog.tsx", + "test": false, + "name": "RunSetupDialog.tsx" + } + ], + "mainFile": "client/src/views/components/RunSetupDialog.tsx", + "origin": "AUTHORED", + "exported": true + }, + "lumieducation.lumi/run-upload-dialog@1.0.5": { + "files": [ + { + "relativePath": "client/src/views/components/RunUploadDialog.tsx", + "test": false, + "name": "RunUploadDialog.tsx" + } + ], + "mainFile": "client/src/views/components/RunUploadDialog.tsx", + "origin": "AUTHORED", + "exported": true + }, + "version": "14.8.8" +} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..ff8668fbc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,112 @@ +name: Build + +on: + push: + branches: + - build + +jobs: + build-mac: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 10.x + uses: actions/setup-node@v1 + with: + node-version: 10.x + - name: NPM Setup + run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: install + run: npm ci && (cd client && npm ci) && (cd reporter-client && npm ci) + - name: Build macOS + run: npm run build:mac + env: + CI: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_LINK: ${{ secrets.mac_certs }} + CSC_KEY_PASSWORD: ${{ secrets.mac_certs_password }} + APPLEID: ${{ secrets.apple_id }} + APPLEIDPASSWORD: ${{ secrets.apple_id_password }} + - run: rm -rf dist/mac/ + - name: Upload macOS artifacts + uses: actions/upload-artifact@v2 + with: + name: macOS + path: dist/ + + build-win: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 10.x + uses: actions/setup-node@v1 + with: + node-version: 10.x + - name: NPM Setup + uses: filipstefansson/set-npm-token-action@v1 + with: + token: ${{ secrets.NPM_TOKEN }} + - name: install server + run: npm ci + env: + NPM_TOKEN: $${ secrets.NPM_TOKEN } + - name: Copy NPM config + run: cp ./.npmrc ./client/.npmrc && cp ./.npmrc ./reporter-client/.npmrc + - name: install client(s) + run: (cd client && npm ci) && (cd ../reporter-client && npm ci) + env: + NPM_TOKEN: $${ secrets.NPM_TOKEN } + - name: Build Windows + run: npm run build:win + env: + CI: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APPX_APPLICATION_ID: ${{ secrets.APPX_APPLICATION_ID }} + APPX_DISPLAY_NAME: ${{ secrets.APPX_DISPLAY_NAME }} + APPX_IDENTITY_NAME: ${{ secrets.APPX_IDENTITY_NAME }} + APPX_PUBLISHER: ${{ secrets.APPX_PUBLISHER }} + APPX_PUBLISHER_DISPLAY_NAME: ${{ secrets.APPX_PUBLISHER_DISPLAY_NAME }} + - run: Remove-Item 'dist\win-unpacked' -Recurse -Force + - name: Upload Windows artifacts + uses: actions/upload-artifact@v2 + with: + name: Windows + path: dist + + build-linux: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 10.x + uses: actions/setup-node@v1 + with: + node-version: 10.x + - name: NPM Setup + run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: install labarchive-tools + run: sudo apt install libarchive-tools + - name: install + run: npm ci && (cd client && npm ci) && (cd reporter-client && npm ci) + - name: Build + run: npm run build + - name: Install Snapcraft + uses: samuelmeuli/action-snapcraft@v1 + with: + snapcraft_token: ${{ secrets.snapcraft_token }} + - name: Build Linux + run: npm run build:linux:dev + env: + CI: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload Linux artifacts + uses: actions/upload-artifact@v2 + with: + name: Linux + path: dist/ diff --git a/client/src/helpers/LumiError.ts b/client/src/helpers/LumiError.ts new file mode 100644 index 000000000..141a20c8b --- /dev/null +++ b/client/src/helpers/LumiError.ts @@ -0,0 +1,20 @@ +export type ErrorCodes = 'user-abort' | 'h5p-not-found'; + +export default class LumiError { + constructor( + code: ErrorCodes, + message: string, + status: number = 500, + error?: Error + ) { + this.code = code; + this.message = message; + this.status = status; + this.error = new Error(message); + } + + public code: ErrorCodes; + public error: Error; + public message: string; + public status: number; +} diff --git a/client/src/state/H5PEditor/H5PEditorActions.ts b/client/src/state/H5PEditor/H5PEditorActions.ts index 2e7a9c4be..a060edb3d 100644 --- a/client/src/state/H5PEditor/H5PEditorActions.ts +++ b/client/src/state/H5PEditor/H5PEditorActions.ts @@ -461,21 +461,21 @@ export function save( Sentry.captureException(error); } - dispatch({ + return dispatch({ // tslint:disable-next-line: object-shorthand-properties-first payload: { id: data.contentId, ...response.body }, type: H5PEDITOR_SAVE_SUCCESS }); } catch (error) { if (error.status === 499) { - dispatch({ + return dispatch({ payload: {}, type: H5PEDITOR_SAVE_CANCEL }); } else { Sentry.captureException(error); - dispatch({ + return dispatch({ error, payload: { path }, type: H5PEDITOR_SAVE_ERROR diff --git a/client/src/state/Notifications/NotificationsActions.ts b/client/src/state/Notifications/NotificationsActions.ts index e10bfe04a..d83e9ba51 100644 --- a/client/src/state/Notifications/NotificationsActions.ts +++ b/client/src/state/Notifications/NotificationsActions.ts @@ -1,9 +1,14 @@ import { CLOSE_SNACKBAR, ENQUEUE_SNACKBAR, + IShowErrorDialog, NotificationActionTypes, NotificationTypes, - REMOVE_SNACKBAR + REMOVE_SNACKBAR, + SHOW_ERROR_DIALOG, + ErrorTypes, + ICloseErrorDialog, + CLOSE_ERROR_DIALOG } from './NotificationsTypes'; import shortid from 'shortid'; @@ -24,6 +29,30 @@ export function notify( }; } +export function showErrorDialog( + code: ErrorTypes, + message: string, + redirect?: string +): IShowErrorDialog { + return { + payload: { + error: { + code, + message, + redirect + } + }, + type: SHOW_ERROR_DIALOG + }; +} + +export function closeErrorDialog(): ICloseErrorDialog { + return { + payload: {}, + type: CLOSE_ERROR_DIALOG + }; +} + export function closeSnackbar(key: string): NotificationActionTypes { return { key, diff --git a/client/src/state/Notifications/NotificationsReducer.ts b/client/src/state/Notifications/NotificationsReducer.ts index 17c1d1545..cdd78c584 100644 --- a/client/src/state/Notifications/NotificationsReducer.ts +++ b/client/src/state/Notifications/NotificationsReducer.ts @@ -6,7 +6,11 @@ import { REMOVE_SNACKBAR, INotifyAction, ICloseSnackbar, - IRemoveSnackbar + IRemoveSnackbar, + SHOW_ERROR_DIALOG, + IShowErrorDialog, + ICloseErrorDialog, + CLOSE_ERROR_DIALOG } from './NotificationsTypes'; import { @@ -36,7 +40,12 @@ import i18next from 'i18next'; import shortid from 'shortid'; export const initialState: INotificationsState = { - notifications: [] + notifications: [], + showErrorDialog: false, + error: { + code: 'init', + message: '' + } }; export default function notificationsReducer( @@ -53,9 +62,29 @@ export default function notificationsReducer( | IH5PImportErrorAction | IAnalyticsImportSuccessAction | IAnalyticsImportErrorAction + | IShowErrorDialog + | ICloseErrorDialog ): INotificationsState { try { switch (action.type) { + case SHOW_ERROR_DIALOG: + return { + ...state, + showErrorDialog: true, + error: action.payload.error + }; + + case CLOSE_ERROR_DIALOG: + return { + ...state, + error: { + code: 'init', + message: '', + redirect: undefined + }, + showErrorDialog: false + }; + case ANALYTICS_IMPORT_ERROR: return { ...state, diff --git a/client/src/state/Notifications/NotificationsTypes.ts b/client/src/state/Notifications/NotificationsTypes.ts index f0e5791f8..bd29c62c5 100644 --- a/client/src/state/Notifications/NotificationsTypes.ts +++ b/client/src/state/Notifications/NotificationsTypes.ts @@ -9,6 +9,11 @@ export const ENQUEUE_SNACKBAR = 'ENQUEUE_SNACKBAR'; export const CLOSE_SNACKBAR = 'CLOSE_SNACKBAR'; export const REMOVE_SNACKBAR = 'REMOVE_SNACKBAR'; +export const SHOW_ERROR_DIALOG = 'SHOW_ERROR_DIALOG'; +export const CLOSE_ERROR_DIALOG = 'CLOSE_ERROR_DIALOG'; + +export type ErrorTypes = 'init' | 'econnrefused' | 'errors.codes.econnrefused'; + export interface INotification { dismissed?: boolean; key: string; @@ -25,7 +30,30 @@ export interface IState { export interface INotificationsState { notifications: INotification[]; + showErrorDialog: boolean; + error: { + code: ErrorTypes; + message: string; + redirect?: string; + }; } + +export interface IShowErrorDialog { + payload: { + error: { + code: ErrorTypes; + message: string; + redirect?: string; + }; + }; + type: typeof SHOW_ERROR_DIALOG; +} + +export interface ICloseErrorDialog { + payload: {}; + type: typeof CLOSE_ERROR_DIALOG; +} + export interface INotifyAction { notification: INotification; type: typeof ENQUEUE_SNACKBAR; @@ -45,4 +73,5 @@ export interface IRemoveSnackbar { export type NotificationActionTypes = | INotifyAction | ICloseSnackbar - | IRemoveSnackbar; + | IRemoveSnackbar + | IShowErrorDialog; diff --git a/client/src/state/Run/RunAPI.ts b/client/src/state/Run/RunAPI.ts index 0387bc692..42c93a6d1 100644 --- a/client/src/state/Run/RunAPI.ts +++ b/client/src/state/Run/RunAPI.ts @@ -3,16 +3,18 @@ import superagent from 'superagent'; import { IRunState } from './RunTypes'; export async function getRuns(): Promise { - return (await superagent.get(`/api/v1/run`)).body; + const body = (await await superagent.get(`/api/run`)).body; + + if (body === null) { + throw new Error('invalid body'); + } + return body; } -export async function upload(): Promise { - return (await superagent.post(`/api/v1/run/upload`)).body; +export async function upload(contentId?: string): Promise { + return (await superagent.post(`/api/run`).send({ contentId })).body; } -export async function deleteFromRun( - id: string, - secret: string -): Promise { - return await superagent.delete(`/api/v1/run/${id}?secret=${secret}`); +export async function deleteFromRun(id: string): Promise { + return await superagent.delete(`/api/run/${id}`); } diff --git a/client/src/state/Run/RunActions.ts b/client/src/state/Run/RunActions.ts index bda485935..95180f399 100644 --- a/client/src/state/Run/RunActions.ts +++ b/client/src/state/Run/RunActions.ts @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/browser'; +import i18next from 'i18next'; import { RUN_GET_RUNS_REQUEST, @@ -10,11 +11,17 @@ import { RUN_DELETE_REQUEST, RUN_DELETE_SUCCESS, RUN_DELETE_ERROR, + RUN_NOT_AUTHORIZED, IRunUpdateState, RUN_UPDATE_STATE, IRunState } from './RunTypes'; +import store from '../../state'; + +import { updateContentOnServer } from '../H5PEditor/H5PEditorActions'; +import { notify, showErrorDialog } from '../Notifications/NotificationsActions'; + import * as API from './RunAPI'; export function getRuns(): any { @@ -26,16 +33,22 @@ export function getRuns(): any { }); try { - const settings = await API.getRuns(); + const runResponse = await API.getRuns(); - dispatch({ - payload: settings, + return dispatch({ + payload: runResponse, type: RUN_GET_RUNS_SUCCESS }); } catch (error) { - Sentry.captureException(error); + if (error.status === 401) { + return dispatch({ + payload: {}, + type: RUN_NOT_AUTHORIZED + }); + } - dispatch({ + Sentry.captureException(error); + return dispatch({ payload: { error }, type: RUN_GET_RUNS_ERROR }); @@ -46,28 +59,60 @@ export function getRuns(): any { }; } -export function upload(options?: { includeReporter?: boolean; path?: string }) { +export function upload(options?: { path?: string; contentId?: string }) { return async (dispatch: any) => { try { + const settings = store.getState().settings; + if (!settings.email || !settings.token) { + return dispatch(updateState({ showSetupDialog: true })); + } + + dispatch(updateState({ showUploadDialog: true })); + let contentId = options?.contentId; + + if (!options?.path && contentId) { + const data = await dispatch(updateContentOnServer()); + contentId = data.contentId; + } + dispatch({ payload: {}, type: RUN_UPLOAD_REQUEST }); try { - const run = await API.upload(); + const run = await API.upload(contentId); dispatch({ payload: run, type: RUN_UPLOAD_SUCCESS }); + dispatch( + notify( + i18next.t('run.notifications.upload.success'), + 'success' + ) + ); + dispatch(getRuns()); } catch (error) { - Sentry.captureException(error); + if (error.status !== 499) { + Sentry.captureException(error); - dispatch({ - payload: { error }, - type: RUN_UPLOAD_ERROR - }); + dispatch( + showErrorDialog( + error.code || 'errors.codes.econnrefused', + 'run.dialog.error.description' + ) + ); + + dispatch({ + payload: { error }, + type: RUN_UPLOAD_ERROR + }); + } + + // user canceled electrons openfile dialog + dispatch(updateState({ showUploadDialog: false })); } } catch (error) { Sentry.captureException(error); @@ -75,7 +120,7 @@ export function upload(options?: { includeReporter?: boolean; path?: string }) { }; } -export function deleteFromRun(id: string, secret: string): any { +export function deleteFromRun(id: string): any { return async (dispatch: any) => { try { dispatch({ @@ -84,13 +129,19 @@ export function deleteFromRun(id: string, secret: string): any { }); try { - const run = await API.deleteFromRun(id, secret); + const run = await API.deleteFromRun(id); dispatch({ payload: run, type: RUN_DELETE_SUCCESS }); dispatch(getRuns()); + dispatch( + notify( + i18next.t('run.notifications.delete.success', { id }), + 'success' + ) + ); } catch (error) { Sentry.captureException(error); diff --git a/client/src/state/Run/RunReducer.ts b/client/src/state/Run/RunReducer.ts index 75199708d..b5063cb7a 100644 --- a/client/src/state/Run/RunReducer.ts +++ b/client/src/state/Run/RunReducer.ts @@ -6,23 +6,18 @@ import { RunActionTypes, RUN_UPLOAD_SUCCESS, RUN_UPLOAD_REQUEST, - RUN_UPDATE_STATE + RUN_UPDATE_STATE, + RUN_UPLOAD_ERROR } from './RunTypes'; export const initialState: IRunState = { runs: [], - showDialog: false, + showSetupDialog: false, + showConnectionErrorDialog: false, + showUploadDialog: false, uploadProgress: { - import: { - state: 'not_started' - }, - export: { - state: 'not_started' - }, - upload: { - state: 'not_started', - progress: 0 - } + state: 'not_started', + progress: 0 } }; @@ -37,13 +32,24 @@ export default function runReducer( switch (action.type) { case RUN_UPLOAD_REQUEST: return { - ...state + ...state, + uploadProgress: { + state: 'pending', + progress: 0, + runId: undefined + } }; case RUN_GET_RUNS_SUCCESS: - case RUN_UPLOAD_SUCCESS: + // case RUN_UPLOAD_SUCCESS: return { ...state, - ...action.payload + runs: action.payload.runList + }; + + case RUN_UPLOAD_ERROR: + return { + ...state, + showConnectionErrorDialog: true }; case RUN_UPDATE_STATE: @@ -52,6 +58,15 @@ export default function runReducer( ...action.payload }; + case RUN_UPLOAD_SUCCESS: + return { + ...state, + uploadProgress: { + state: 'success', + progress: 100, + runId: action.payload.runId + } + }; // case 'RUN_UPDATE_UPLOAD_PROGRESS': // return { // ...state, diff --git a/client/src/state/Run/RunTypes.ts b/client/src/state/Run/RunTypes.ts index f98b02b35..4e2d07c0c 100644 --- a/client/src/state/Run/RunTypes.ts +++ b/client/src/state/Run/RunTypes.ts @@ -1,27 +1,27 @@ +import { ContentId } from '@lumieducation/h5p-server'; import { IGetSettingsErrorAction } from '../Settings/SettingsTypes'; export interface IRun { - id: string; - secret: string; + runId: string; title: string; mainLibrary: string; } -type uploadProgressStates = 'not_started' | 'pending' | 'success' | 'error'; +type uploadProgressStates = + | 'not_started' + | 'pending' + | 'success' + | 'error' + | 'processing'; export interface IRunState { runs: IRun[]; - showDialog: boolean; + showSetupDialog: boolean; + showConnectionErrorDialog: boolean; + showUploadDialog: boolean; uploadProgress: { - import: { - state: uploadProgressStates; - }; - export: { - state: uploadProgressStates; - }; - upload: { - state: uploadProgressStates; - progress: number; - }; + runId?: string; + state: uploadProgressStates; + progress: number; }; } @@ -39,6 +39,7 @@ export interface IRunUpdateState { export const RUN_GET_RUNS_REQUEST = 'RUN_GET_RUNS_REQUEST'; export const RUN_GET_RUNS_SUCCESS = 'RUN_GET_RUNS_SUCCESS'; export const RUN_GET_RUNS_ERROR = 'RUN_GET_RUNS_ERROR'; +export const RUN_NOT_AUTHORIZED = 'RUN_NOT_AUTHORIZED'; export interface IGetRunsRequestAction { payload: {}; @@ -46,7 +47,9 @@ export interface IGetRunsRequestAction { } export interface IGetRunsSuccessAction { - payload: IRunState; + payload: { + runList: IRun[]; + }; type: typeof RUN_GET_RUNS_SUCCESS; } export interface IGetRunsErrorAction { @@ -68,7 +71,9 @@ export interface IRunUploadRequestAction { } export interface IRunUploadSuccessAction { - payload: IRunState; + payload: { + runId: ContentId; + }; type: typeof RUN_UPLOAD_SUCCESS; } export interface IRunUploadErrorAction { diff --git a/client/src/views/App.tsx b/client/src/views/App.tsx index e0ad49a26..cf92f5919 100644 --- a/client/src/views/App.tsx +++ b/client/src/views/App.tsx @@ -12,8 +12,6 @@ import Notifications from './Notifications'; import RunPage from './Run'; -import RunUploadDialog from './components/RunUploadDialog'; - import H5PEditor from './H5PEditor'; import Analytics from './Analytics'; import Launchpad from './Launchpad'; @@ -21,6 +19,10 @@ import Launchpad from './Launchpad'; import SetupDialog from './components/SetupDialog'; import Backdrop from './components/Backdrop'; import Websocket from './Websocket'; +import RunSetupDialogContainer from './container/RunSetupDialogContainer'; +import RunUploadDialogContainer from './container/RunUploadDialogContainer'; + +import ErrorDialog from './components/ErrorDialog'; import { actions } from '../state'; @@ -44,6 +46,7 @@ export default function AppContainer() { dispatch(actions.system.getSystem()); }, [dispatch, i18n]); + console.log(process.env.TARGET); return (
@@ -65,9 +68,12 @@ export default function AppContainer() { - + + + +
); diff --git a/client/src/views/Launchpad.tsx b/client/src/views/Launchpad.tsx index fd3ec42de..ef8f97dc9 100644 --- a/client/src/views/Launchpad.tsx +++ b/client/src/views/Launchpad.tsx @@ -13,7 +13,7 @@ import Button from '@material-ui/core/Button'; import Typography from '@material-ui/core/Typography'; import Grid from '@material-ui/core/Grid'; import AnalyticsIcon from '@material-ui/icons/ShowChart'; -// import RunIcon from '@material-ui/icons/CloudUpload'; +import RunIcon from '@material-ui/icons/CloudUpload'; import { useTranslation } from 'react-i18next'; import MainSection from './components/MainSection'; @@ -207,7 +207,7 @@ export default function Launchpad() { - {/* + - Lumi Run allows you to upload H5P to - Lumi.run and host your H5P online. + {t('run.description')} @@ -258,7 +257,7 @@ export default function Launchpad() { - */} + diff --git a/client/src/views/Run.tsx b/client/src/views/Run.tsx index 58065bbc6..cf4d041d2 100644 --- a/client/src/views/Run.tsx +++ b/client/src/views/Run.tsx @@ -1,7 +1,6 @@ import React, { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; - -import { actions } from '../state'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; import { createStyles, Theme, makeStyles } from '@material-ui/core/styles'; @@ -12,6 +11,9 @@ import CloudUploadIcon from '@material-ui/icons/CloudUpload'; import RunList from './components/RunList'; +import { RUN_GET_RUNS_ERROR, RUN_NOT_AUTHORIZED } from '../state/Run/RunTypes'; +import { actions, IState } from '../state'; + const useStyles = makeStyles((theme: Theme) => createStyles({ root: { @@ -32,20 +34,48 @@ const useStyles = makeStyles((theme: Theme) => }) ); -export default function FolderList() { +export default function RunContainer() { const classes = useStyles(); const dispatch = useDispatch(); + const { t } = useTranslation(); + const runs = useSelector((state: IState) => state.run.runs); + useEffect(() => { - dispatch(actions.run.getRuns()); - }); + dispatch(actions.run.getRuns()).then((action: any) => { + if (action.type === RUN_NOT_AUTHORIZED) { + dispatch(actions.run.updateState({ showSetupDialog: true })); + } + if (action.type === RUN_GET_RUNS_ERROR) { + dispatch( + actions.notifications.showErrorDialog( + 'errors.codes.econnrefused', + 'run.dialog.error.description', + '/' + ) + ); + } + }); + }, [dispatch]); + + const onCopy = (runId: string) => { + navigator.clipboard.writeText(`https://Lumi.run/${runId}`); + dispatch( + actions.notifications.notify( + t('general.copyClipboard', { + value: `https://Lumi.run/${runId}` + }), + 'success' + ) + ); + }; + + const onDelete = (runId: string) => { + dispatch(actions.run.deleteFromRun(runId)); + }; return (
- - dispatch(actions.run.deleteFromRun(id, secret)) - } - /> + @@ -59,7 +89,7 @@ export default function FolderList() { color="primary" startIcon={} > - Upload + {t('run.upload')} diff --git a/client/src/views/Settings.tsx b/client/src/views/Settings.tsx index 2ecd9f2a2..86b6598d6 100644 --- a/client/src/views/Settings.tsx +++ b/client/src/views/Settings.tsx @@ -27,7 +27,7 @@ import CloseIcon from '@material-ui/icons/Close'; import SettingsIcon from '@material-ui/icons/Settings'; import LibraryBooksIcon from '@material-ui/icons/LibraryBooks'; // import LibraryBooksIcon from '@material-ui/icons/LibraryBooks'; -// import AccountBoxIcon from '@material-ui/icons/AccountBox'; +import AccountBoxIcon from '@material-ui/icons/AccountBox'; import UpdateIcon from '@material-ui/icons/Update'; import SettingsList from './components/Settings/GeneralSettingsList'; @@ -208,7 +208,7 @@ export default function FullScreenDialog() { )} /> - {/* setSection('account')} @@ -222,7 +222,7 @@ export default function FullScreenDialog() { - */} + diff --git a/client/src/views/Websocket.tsx b/client/src/views/Websocket.tsx index cecfe0c06..d6b907aa6 100644 --- a/client/src/views/Websocket.tsx +++ b/client/src/views/Websocket.tsx @@ -100,6 +100,14 @@ export class WebsocketContainer extends React.Component< dispatch(actions.h5peditor.openExportDialog()); break; + case 'UPLOAD_TO_RUN': + dispatch( + actions.run.upload({ + contentId: this.props.activeTab.contentId + }) + ); + break; + case 'MESSAGE': dispatch( actions.notifications.notify( diff --git a/client/src/views/Auth.tsx b/client/src/views/components/Auth.tsx similarity index 78% rename from client/src/views/Auth.tsx rename to client/src/views/components/Auth.tsx index eee44eeee..87914f061 100644 --- a/client/src/views/Auth.tsx +++ b/client/src/views/components/Auth.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useDispatch } from 'react-redux'; import classnames from 'classnames'; import { @@ -17,12 +16,9 @@ import DialogContent from '@material-ui/core/DialogContent'; import CssBaseline from '@material-ui/core/CssBaseline'; import Grid from '@material-ui/core/Grid'; import Container from '@material-ui/core/Container'; - import superagent from 'superagent'; -import Logo from './components/Logo'; - -import { actions } from '../state'; +import Logo from './Logo'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -79,10 +75,16 @@ const CssTextField = withStyles({ } })(TextField); -export default function FormDialog() { +export interface IAuthProps { + loggedIn: boolean; + handleLogout: () => void; + handleLogin: (email: string, token: string) => void; +} +export default function Auth(props: IAuthProps): JSX.Element { const classes = useStyles(); const { t } = useTranslation(); - const dispatch = useDispatch(); + + const { loggedIn, handleLogin, handleLogout } = props; const [open, setOpen] = React.useState(false); const [email, setEmail] = React.useState(''); @@ -106,11 +108,31 @@ export default function FormDialog() { setMessage(''); }; + const handleError = async (error: superagent.ResponseError) => { + try { + const { status } = error; + + switch (status) { + case 500: + setMessage('auth.error.econnrefused'); + setError(true); + + break; + default: + handleLogout(); + } + } catch (error) { + setError(true); + setMessage('auth.something_went_wrong'); + } + }; + const handleSendCode = async () => { - const validateEmail = - /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - String(email).toLowerCase() - ); + setError(false); + setMessage(''); + const validateEmail = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + String(email).toLowerCase() + ); if (!validateEmail) { setMessage('auth.error.no-valid-email'); @@ -118,41 +140,51 @@ export default function FormDialog() { return; } try { - await superagent.post(`/api/v1/auth/register`).send({ email }); + await superagent + .post(`/api/v1/auth/api/v1/auth/register`) + .send({ email }); setMessage('auth.notification.pending'); setEnterCode(true); } catch (error) { - setMessage('auth.no-valid-email'); - setError(true); + handleError(error); } }; const handleVerification = async () => { - const { body } = await superagent.post(`/api/v1/auth/login`).send({ - code - }); - - dispatch(actions.settings.changeSetting({ email, token: body.token })); - - setOpen(false); + try { + const { body } = await superagent + .post(`/api/v1/auth/api/v1/auth/login`) + .send({ + code + }); - dispatch( - actions.notifications.notify( - t('auth.notification.success', { email }), - 'success' - ) - ); + setOpen(false); + handleLogin(email, body.token); + } catch (error) { + handleError(error); + } }; return (
- + {loggedIn ? ( + + ) : ( + + )} + }, + ref: React.Ref +) { + return ; +}); + +export default function ErrorDialog() { + const { t } = useTranslation(); + const history = useHistory(); + const dispatch = useDispatch(); + const open = useSelector( + (state: IState) => state.notifications.showErrorDialog + ); + + const code = useSelector( + (state: IState) => state.notifications.error?.code + ); + + const message = useSelector( + (state: IState) => state.notifications.error?.message + ); + + const redirect = useSelector( + (state: IState) => state.notifications.error.redirect + ); + + const close = () => { + dispatch(actions.notifications.closeErrorDialog()); + + if (redirect) { + history.push(redirect); + } + }; + + return ( + + {t(code)} + + + {t(message)} + + + + + + + ); +} diff --git a/client/src/views/components/RunLink.tsx b/client/src/views/components/RunLink.tsx new file mode 100644 index 000000000..514d0797c --- /dev/null +++ b/client/src/views/components/RunLink.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import clsx from 'clsx'; + +import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; +import IconButton from '@material-ui/core/IconButton'; +import OutlinedInput from '@material-ui/core/OutlinedInput'; +import InputLabel from '@material-ui/core/InputLabel'; +import InputAdornment from '@material-ui/core/InputAdornment'; +import FormControl from '@material-ui/core/FormControl'; + +import FileCopyIcon from '@material-ui/icons/FileCopy'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + display: 'flex', + flexWrap: 'wrap' + }, + margin: { + margin: theme.spacing(1) + }, + withoutLabel: { + marginTop: theme.spacing(3) + }, + textField: { + width: '60ch' + } + }) +); + +export default function RunLink(props: { + runId: string; + onCopy: (runId: string) => void; +}) { + const classes = useStyles(); + const { t } = useTranslation(); + const { runId, onCopy } = props; + + return ( + + + {t('run.link.header')} + + + onCopy(runId)} + > + + + + } + labelWidth={70} + /> + + ); +} diff --git a/client/src/views/components/RunList.tsx b/client/src/views/components/RunList.tsx index a526951ab..c25f55012 100644 --- a/client/src/views/components/RunList.tsx +++ b/client/src/views/components/RunList.tsx @@ -1,21 +1,33 @@ import React from 'react'; -import { useSelector } from 'react-redux'; - -import { IState } from '../../state'; +import { useTranslation } from 'react-i18next'; import { createStyles, Theme, makeStyles } from '@material-ui/core/styles'; + import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; import ListItemAvatar from '@material-ui/core/ListItemAvatar'; import Divider from '@material-ui/core/Divider'; - -import TextField from '@material-ui/core/TextField'; -import DeleteIcon from '@material-ui/icons/Delete'; import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; import IconButton from '@material-ui/core/IconButton'; import H5PAvatar from './H5PAvatar'; import ListSubheader from '@material-ui/core/ListSubheader'; +import Button from '@material-ui/core/Button'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import DialogTitle from '@material-ui/core/DialogTitle'; + +import DeleteIcon from '@material-ui/icons/Delete'; + +import RunLink from './RunLink'; + +export interface IRun { + runId: string; + title: string; + mainLibrary: string; +} const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -33,56 +45,100 @@ const useStyles = makeStyles((theme: Theme) => }) ); -export default function FolderList(props: { - deleteCallback: (id: string, secret: string) => void; +export default function RunList(props: { + runs: IRun[]; + onDelete: (runId: string) => void; + onCopy: (runId: string) => void; }) { const classes = useStyles(); - const runs = useSelector((state: IState) => state.run.runs); + const { t } = useTranslation(); + const { runs } = props; - return ( - Uploaded H5P} - className={classes.root} - > - {runs.length === 0 && ( - - - - )} - {runs.map((run) => ( -
- + const [showDeleteDialog, setShowDeleteDialog] = React.useState(false); + const [runIdToDelete, setRunIdToDelete] = React.useState(''); + return ( +
+ {t('run.list.header')} + } + className={classes.root} + > + {runs.length === 0 && ( - - - - -
-
- - -
- - - props.deleteCallback(run.id, run.secret) - } - edge="end" - aria-label="delete" - > - - - +
-
- ))} - + )} + {runs.map((run) => ( +
+ + + + + + + +
+ +
+ + { + setRunIdToDelete(run.runId); + setShowDeleteDialog(true); + }} + edge="end" + aria-label="delete" + > + + + +
+
+ ))} + + + setShowDeleteDialog(false)} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + + {t('run.list.deleteDialog.title', { runId: runIdToDelete })} + + + + {t('run.list.deleteDialog.description', { + runId: runIdToDelete + })} + + + + + + + +
); } diff --git a/client/src/views/components/RunSetupDialog.tsx b/client/src/views/components/RunSetupDialog.tsx new file mode 100644 index 000000000..3db88951d --- /dev/null +++ b/client/src/views/components/RunSetupDialog.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { + createStyles, + Theme, + withStyles, + makeStyles, + WithStyles +} from '@material-ui/core/styles'; + +import superagent from 'superagent'; + +import { useTranslation } from 'react-i18next'; + +import Button from '@material-ui/core/Button'; +import Dialog from '@material-ui/core/Dialog'; +import MuiDialogTitle from '@material-ui/core/DialogTitle'; +import MuiDialogContent from '@material-ui/core/DialogContent'; +import MuiDialogActions from '@material-ui/core/DialogActions'; +import IconButton from '@material-ui/core/IconButton'; +import CloseIcon from '@material-ui/icons/Close'; +import Typography from '@material-ui/core/Typography'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; +import ListItemText from '@material-ui/core/ListItemText'; +import Switch from '@material-ui/core/Switch'; +import PolicyIcon from '@material-ui/icons/Policy'; +import EmailIcon from '@material-ui/icons/Email'; +import AssignmentIcon from '@material-ui/icons/Assignment'; + +import Auth, { IAuthProps } from './Auth'; + +const styles = (theme: Theme) => + createStyles({ + root: { + margin: 0, + padding: theme.spacing(2) + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500] + } + }); + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + width: '100%', + backgroundColor: theme.palette.background.paper + } + }) +); +export interface DialogTitleProps extends WithStyles { + id: string; + children: React.ReactNode; + onClose: () => void; +} + +const DialogTitle = withStyles(styles)((props: DialogTitleProps) => { + const { children, classes, onClose, ...other } = props; + return ( + + {children} + {onClose ? ( + + + + ) : null} + + ); +}); + +const DialogContent = withStyles((theme: Theme) => ({ + root: { + padding: theme.spacing(2) + } +}))(MuiDialogContent); + +const DialogActions = withStyles((theme: Theme) => ({ + root: { + margin: 0, + padding: theme.spacing(1) + } +}))(MuiDialogActions); + +export interface IRunSetupDialogProps extends IAuthProps { + open: boolean; + onClose: () => void; + email?: string; + onConsent: () => void; +} +export default function RunSetupDialog(props: IRunSetupDialogProps) { + const { t } = useTranslation(); + const { email, open, onClose, onConsent } = props; + + const handleConsent = async () => { + try { + await superagent.post(`/api/run/consent`); + onConsent(); + } catch (error) {} + }; + + const classes = useStyles(); + const [privacyPolicyConsent, setPrivacyPolicyConsent] = React.useState( + false + ); + const [tosConsent, setTosConsent] = React.useState(false); + + return ( + + + Lumi Run + + + {t('run.description')} + {t('run.legal')} + + + + + + + + {t('privacy_policy.title')} [ + + Link + + ] + + } + secondary={t('privacy_policy.consent')} + /> + + + setPrivacyPolicyConsent( + !privacyPolicyConsent + ) + } + checked={privacyPolicyConsent} + inputProps={{ + 'aria-labelledby': 'switch-list-label-pp' + }} + /> + + + + + + + + {t('run.tos.header')} [ + + Link + + ] + + } + secondary={t('run.tos.description')} + /> + + setTosConsent(!tosConsent)} + checked={tosConsent} + inputProps={{ + 'aria-labelledby': 'switch-list-label-tos' + }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/client/src/views/components/RunUploadDialog.tsx b/client/src/views/components/RunUploadDialog.tsx index 73fbf8041..b4a2f712e 100644 --- a/client/src/views/components/RunUploadDialog.tsx +++ b/client/src/views/components/RunUploadDialog.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import { useTranslation } from 'react-i18next'; import { createStyles, @@ -13,26 +13,19 @@ import Dialog from '@material-ui/core/Dialog'; import MuiDialogTitle from '@material-ui/core/DialogTitle'; import MuiDialogContent from '@material-ui/core/DialogContent'; import MuiDialogActions from '@material-ui/core/DialogActions'; -import IconButton from '@material-ui/core/IconButton'; -import CloseIcon from '@material-ui/icons/Close'; import Typography from '@material-ui/core/Typography'; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; -import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; -import ListItemText from '@material-ui/core/ListItemText'; +import LinearProgress from '@material-ui/core/LinearProgress'; -import CloudUploadIcon from '@material-ui/icons/CloudUpload'; -import CodeIcon from '@material-ui/icons/Code'; -import DoneIcon from '@material-ui/icons/Done'; -import ImportExportIcon from '@material-ui/icons/ImportExport'; -import ErrorIcon from '@material-ui/icons/Error'; -import { actions, IState } from '../../state'; -import CircularProgress, { - CircularProgressProps -} from '@material-ui/core/CircularProgress'; -import Box from '@material-ui/core/Box'; -import { Link } from 'react-router-dom'; +import RunLink from './RunLink'; + +type uploadProgressStates = + | 'not_started' + | 'pending' + | 'success' + | 'error' + | 'processing'; const styles = (theme: Theme) => createStyles({ @@ -46,29 +39,22 @@ const styles = (theme: Theme) => right: theme.spacing(1), top: theme.spacing(1), color: theme.palette.grey[500] + }, + link: { + minWidth: '280px' } }); export interface DialogTitleProps extends WithStyles { id: string; children: React.ReactNode; - onClose: () => void; } const DialogTitle = withStyles(styles)((props: DialogTitleProps) => { - const { children, classes, onClose, ...other } = props; + const { children, classes, ...other } = props; return ( {children} - {onClose ? ( - - - - ) : null} ); }); @@ -86,161 +72,78 @@ const DialogActions = withStyles((theme: Theme) => ({ } }))(MuiDialogActions); -export default function CustomizedDialogs() { - const showDialog = useSelector((state: IState) => state.run.showDialog); - const uploadProgress = useSelector( - (state: IState) => state.run.uploadProgress - ); - const dispatch = useDispatch(); +export default function RunUploadDialog(props: { + open: boolean; + uploadProgress: { + runId?: string; + state: uploadProgressStates; + progress: number; + }; + goToRun: () => void; + onCopy: (runId: string) => void; + onClose: () => void; +}) { + const { t } = useTranslation(); + const { open, uploadProgress, goToRun, onCopy, onClose } = props; return ( - - dispatch( - actions.run.updateState({ - showDialog: false - }) - ) - } - aria-labelledby="customized-dialog-title" - open={showDialog} - > - - dispatch( - actions.run.updateState({ - showDialog: false - }) - ) - } - > - Lumi Run - + + Lumi Run - - - - - - - - {uploadStateIcon(uploadProgress.import.state)} - - - - - - - - - {uploadStateIcon(uploadProgress.export.state)} - - - - - - - + {t('run.uploadDialog.success')} + + + + + +
+ )} + {uploadProgress.progress !== 100 && ( +
+ {t('run.uploadDialog.uploading')} + - - {uploadStateIcon( - uploadProgress.upload.state, - uploadProgress.upload.progress - )} - - - +
+ )} + {uploadProgress.progress === 100 && !uploadProgress.runId && ( +
+ {t('run.uploadDialog.processing')} + +
+ )}
- - - + + ); } - -function uploadStateIcon( - state: 'not_started' | 'pending' | 'success' | 'error', - progress?: number -): JSX.Element { - switch (state) { - default: - case 'not_started': - return
; - case 'pending': - return progress ? ( - - ) : ( - - ); - case 'success': - return ( - - - - ); - case 'error': - return ( - - - - ); - } -} - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number } -) { - return ( - - - - {`${Math.round(props.value)}%`} - - - ); -} diff --git a/client/src/views/components/Settings/AccountSettingsList.tsx b/client/src/views/components/Settings/AccountSettingsList.tsx index e364df62a..113a7295f 100644 --- a/client/src/views/components/Settings/AccountSettingsList.tsx +++ b/client/src/views/components/Settings/AccountSettingsList.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; import List from '@material-ui/core/List'; @@ -10,11 +10,11 @@ import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; import ListItemText from '@material-ui/core/ListItemText'; import ListSubheader from '@material-ui/core/ListSubheader'; -import Auth from '../../Auth'; +import Auth from '../Auth'; import EmailIcon from '@material-ui/icons/Email'; -import { IState } from '../../../state'; +import { actions, IState } from '../../../state'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -30,7 +30,7 @@ export default function SettingsAccountSettingsList() { const { t } = useTranslation(); const settings = useSelector((state: IState) => state.settings); - + const dispatch = useDispatch(); return ( - + { + dispatch( + actions.settings.changeSetting({ email, token }) + ); + dispatch( + actions.notifications.notify( + t('auth.notification.login.success', { + email + }), + 'success' + ) + ); + }} + handleLogout={() => { + dispatch( + actions.settings.changeSetting({ + email: undefined, + token: undefined + }) + ); + dispatch( + actions.notifications.notify( + t('auth.notification.logout.success'), + 'success' + ) + ); + }} + /> diff --git a/client/src/views/container/RunSetupDialogContainer.tsx b/client/src/views/container/RunSetupDialogContainer.tsx new file mode 100644 index 000000000..576ec6f18 --- /dev/null +++ b/client/src/views/container/RunSetupDialogContainer.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router'; + +import RunSetupDialog from '../components/RunSetupDialog'; + +import { actions, IState } from '../../state'; + +export default function RunSetupDialogContainer() { + const dispatch = useDispatch(); + const history = useHistory(); + + const { t } = useTranslation(); + const open = useSelector((state: IState) => state.run.showSetupDialog); + const email = useSelector((state: IState) => state.settings.email); + + const onClose = () => { + history.push('/'); + dispatch(actions.run.updateState({ showSetupDialog: false })); + }; + const onConsent = () => { + dispatch(actions.run.updateState({ showSetupDialog: false })); + }; + + return ( + { + dispatch(actions.settings.changeSetting({ email, token })); + dispatch( + actions.notifications.notify( + t('auth.notification.login.success', { + email + }), + 'success' + ) + ); + }} + handleLogout={() => { + dispatch( + actions.settings.changeSetting({ + email: undefined, + token: undefined + }) + ); + dispatch( + actions.notifications.notify( + t('auth.notification.logout.success'), + 'success' + ) + ); + }} + /> + ); +} diff --git a/client/src/views/container/RunUploadDialogContainer.tsx b/client/src/views/container/RunUploadDialogContainer.tsx new file mode 100644 index 000000000..e5e3a01e9 --- /dev/null +++ b/client/src/views/container/RunUploadDialogContainer.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router'; +import { useTranslation } from 'react-i18next'; + +import RunUploadDialog from '../components/RunUploadDialog'; + +import { actions, IState } from '../../state'; + +export default function RunSetupDialogContainer() { + const dispatch = useDispatch(); + const history = useHistory(); + const { t } = useTranslation(); + + const open = useSelector((state: IState) => state.run.showUploadDialog); + + const uploadProgress = useSelector( + (state: IState) => state.run.uploadProgress + ); + + const goToRun = () => { + dispatch(actions.run.updateState({ showUploadDialog: false })); + history.push('/run'); + }; + + const onCopy = (runId: string) => { + navigator.clipboard.writeText(`https://Lumi.run/${runId}`); + dispatch( + actions.notifications.notify( + t('general.copyClipboard', { + value: `https://Lumi.run/${runId}` + }), + 'success' + ) + ); + }; + + const onClose = () => { + dispatch( + actions.run.updateState({ + showUploadDialog: false + }) + ); + }; + + return ( + + ); +} diff --git a/locales/lumi/en.json b/locales/lumi/en.json index fd1bca588..0b0e55dc5 100644 --- a/locales/lumi/en.json +++ b/locales/lumi/en.json @@ -3,7 +3,14 @@ "close": "Close" }, "general": { - "delete": "Delete" + "delete": "Delete", + "copyClipboard": "Copied {{value}} to clipboard" + }, + "errors": { + "codes": { + "econnrefused": "Connection Refused" + }, + "messages": { "econnrefused": "Connection Refused" } }, "auth": { "set_email": "Set Email", @@ -11,15 +18,62 @@ "code": "Code", "verify_code": "Verify Code", "verify_email": "Verify Email", + "logout": "Logout", "notification": { "success": "{{email}} has been successfully validated", - "pending": "A verification code has been send to {{email}}. Please check your emails." + "pending": "A verification code has been send to {{email}}. Please check your emails.", + "logout": { + "success": "Your were logged out." + } }, "error": { - "no-valid-email": "{{email}} is not a valid email address." + "no-valid-email": "{{email}} is not a valid email address.", + "econnrefused": "Could not contact Lumi Auth server. Please check your internet connection." }, "cancel": "Cancel" }, + "run": { + "description": "Lumi Run allows you to upload and host your H5P with one-click.", + "legal": "In order to use this service you have to aggree to the Terms of service, the privacy policy and provide us with a contact email.", + "tos": { + "header": "Terms of Service", + "description": "I have read and consent to the terms of service." + }, + "link": { + "header": "Link" + }, + "upload": "Upload", + "list": { + "header": "Uploaded H5P", + "noUploadedH5P": "No uploaded H5P", + "deleteDialog": { + "title": "Delete Run {{runId}}", + "description": "Do you really want to delete run {{runId}}", + "cancel": "Cancel", + "confirm": "Confirm" + } + }, + "uploadDialog": { + "success": "Your H5P was uploaded to Lumi.run. You can access and share it with the following link.", + "uploading": "Uploading", + "processing": "Processing", + "goToRun": "Go to runs", + "ok": "Ok" + }, + "setupDialog": { + "close": "Close", + "start": "Start" + }, + + "notifications": { + "delete": { + "success": "Successfully deleted {{id}} from Lumi.run" + }, + "upload": { + "success": "Successfully uploaded H5P to Lumi.run" + } + } + }, "editor": { "default_name": "new H5P", "tab": { "view": "View", "edit": "Edit" }, @@ -230,9 +284,16 @@ "toggle_developer_tools": "Toggle Developer Tools", "follow_us_on_twitter": "Follow Us on Twitter", "about": "About" + }, + "run": { + "label": "Run", + "upload": "Upload" } }, "notifications": { + "general": { + "econnrefused": "There seems to be a problem with your internet connection." + }, "analytics": { "import": { "error": "No valid files found", diff --git a/locales/lumi/fr.json b/locales/lumi/fr.json index c1531280b..05bd41bed 100644 --- a/locales/lumi/fr.json +++ b/locales/lumi/fr.json @@ -162,7 +162,9 @@ "brokenFiles": "{{count}} fichier cassé trouvé", "brokenFiles_plural": "{{count}} fichiers cassés trouvés", "error": "Aucun fichier valide trouvé", - "success": "Fichiers de rapport importés" + "success": "Fichiers de rapport importés", + "brokenFiles": "{{count}} fichier cassé trouvé", + "brokenFiles_plural": "{{count}} fichiers cassés trouvés" } }, "h5peditor": { diff --git a/package-lock.json b/package-lock.json index 5aa493706..d2bd95790 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25200,8 +25200,16 @@ "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "optional": true } } }, @@ -27270,7 +27278,8 @@ "dev": true, "optional": true, "requires": { - "cli-truncate": "^1.1.0" + "cli-truncate": "^1.1.0", + "node-addon-api": "^1.6.3" } }, "iconv-lite": { @@ -30326,6 +30335,13 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "optional": true + }, "node-emoji": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.10.0.tgz", diff --git a/package.json b/package.json index 7a279901f..855efe835 100644 --- a/package.json +++ b/package.json @@ -102,5 +102,9 @@ "repository": { "type": "git", "url": "https://github.com/Lumieducation/Lumi.git" + }, + "bit": { + "componentsDefaultDirectory": "client/src/views/components/{name}", + "packageManager": "npm" } } diff --git a/server/src/IServerConfig.ts b/server/src/IServerConfig.ts index 25ae95fb6..45d706bdf 100644 --- a/server/src/IServerConfig.ts +++ b/server/src/IServerConfig.ts @@ -2,7 +2,6 @@ export default interface IServerConfig { cache: string; configFile: string; librariesPath: string; - runFile: string; settingsFile: string; temporaryStoragePath: string; workingCachePath: string; diff --git a/server/src/boot/app.ts b/server/src/boot/app.ts index 999bbba92..68fe37a75 100644 --- a/server/src/boot/app.ts +++ b/server/src/boot/app.ts @@ -8,6 +8,8 @@ import fileUpload from 'express-fileupload'; import i18next from 'i18next'; import i18nextHttpMiddleware from 'i18next-http-middleware'; +import LumiError from '../helpers/LumiError'; + import routes from '../routes'; import IServerConfig from '../IServerConfig'; @@ -146,11 +148,9 @@ export default async ( app.use((error, req, res, next) => { Sentry.captureException(error); - res.status(error.status || 500).json({ - code: error.code, - message: error.message, - status: error.status - }); + res.status(error.status || 500).json( + new LumiError(error.code, error.message, error.status) + ); }); return app; }; diff --git a/server/src/boot/defaultSettings.ts b/server/src/boot/defaultSettings.ts index a954aaba7..ff690a965 100644 --- a/server/src/boot/defaultSettings.ts +++ b/server/src/boot/defaultSettings.ts @@ -7,5 +7,7 @@ export default { usageStatistics: false, lastVersion: app ? app.getVersion() : 'test', autoUpdates: false, - language: 'en' + language: 'en', + email: '', + token: '' }; diff --git a/server/src/boot/setup.ts b/server/src/boot/setup.ts index 17eccc281..c575d126d 100644 --- a/server/src/boot/setup.ts +++ b/server/src/boot/setup.ts @@ -45,21 +45,6 @@ export default async function setup( }); } - // Check if current runsexists and is read- and parsable - let runOk = false; - try { - if (await fsExtra.pathExists(serverConfig.runFile)) { - await fsExtra.readJSON(serverConfig.runFile); - runOk = true; - } - } catch (error) { - runOk = false; - } - - if (!runOk) { - await fsExtra.writeJSON(serverConfig.runFile, defaultRun); - } - // Check if current config exists and is read- and parsable let configOk = false; try { diff --git a/server/src/controllers/LumiController.ts b/server/src/controllers/LumiController.ts index 8a533da47..62df27fba 100644 --- a/server/src/controllers/LumiController.ts +++ b/server/src/controllers/LumiController.ts @@ -20,6 +20,7 @@ export default class LumiController { serverConfig: IServerConfig, private browserWindow: BrowserWindow ) { + this.temporaryStoragePath = serverConfig.temporaryStoragePath; fs.readJSON(serverConfig.settingsFile).then((settings) => { if (settings.privacyPolicyConsent) { h5pEditor.contentTypeCache.updateIfNecessary(); @@ -27,6 +28,8 @@ export default class LumiController { }); } + private temporaryStoragePath: string; + public async delete(contentId: string): Promise { return this.h5pEditor.deleteContent(contentId, new User()); } diff --git a/server/src/helpers/LumiError.ts b/server/src/helpers/LumiError.ts index 1616f06c9..ea9440e5f 100644 --- a/server/src/helpers/LumiError.ts +++ b/server/src/helpers/LumiError.ts @@ -1,7 +1,12 @@ export type ErrorCodes = 'user-abort' | 'h5p-not-found'; export default class LumiError { - constructor(code: ErrorCodes, message?: string, status: number = 500) { + constructor( + code: ErrorCodes, + message?: string, + status: number = 500, + error?: Error + ) { this.code = code; this.message = message; this.status = status; diff --git a/server/src/menu/h5peditorMenu.ts b/server/src/menu/h5peditorMenu.ts index 50c23754b..7071542fa 100644 --- a/server/src/menu/h5peditorMenu.ts +++ b/server/src/menu/h5peditorMenu.ts @@ -7,6 +7,7 @@ import editMenu from './editMenu'; import macMenu from './macMenu'; import windowMenu from './windowMenu'; import viewMenu from './viewMenu'; +import runMenu from './runMenuItem'; export default (window: electron.BrowserWindow, websocket: SocketIO.Server) => [ ...macMenu(), @@ -85,6 +86,7 @@ export default (window: electron.BrowserWindow, websocket: SocketIO.Server) => [ } as any ] }, + runMenu(websocket), editMenu(), ...viewMenu(), ...windowMenu(), diff --git a/server/src/menu/runMenuItem.ts b/server/src/menu/runMenuItem.ts new file mode 100644 index 000000000..51b15f81c --- /dev/null +++ b/server/src/menu/runMenuItem.ts @@ -0,0 +1,21 @@ +import i18next from 'i18next'; +import SocketIO from 'socket.io'; + +const isMac = process.platform === 'darwin'; + +export default function (websocket: SocketIO.Server): any { + return { + label: i18next.t('lumi:menu.run.label'), + submenu: [ + { + label: i18next.t('lumi:menu.run.upload'), + click: () => { + websocket.emit('action', { + payload: {}, + type: 'UPLOAD_TO_RUN' + }); + } + } + ] + }; +} diff --git a/server/src/routes/__test__/analyticRoutes.test.ts b/server/src/routes/__test__/analyticRoutes.test.ts index 2d8f1a26d..fdefbf1d0 100644 --- a/server/src/routes/__test__/analyticRoutes.test.ts +++ b/server/src/routes/__test__/analyticRoutes.test.ts @@ -14,7 +14,6 @@ describe('[analytics:routes]: GET /api/v1/analytics', () => { configFile: path.resolve('test', 'data', 'config.json'), librariesPath: path.resolve('test', 'data', `libraries`), temporaryStoragePath: path.resolve('test', 'data', 'tmp'), - runFile: path.resolve('test', 'data', 'run.json'), workingCachePath: path.resolve('test', 'data', 'workingCache'), settingsFile: path.resolve('test', 'data', 'settings.json') }, diff --git a/server/src/routes/__test__/h5pRoutes.test.ts b/server/src/routes/__test__/h5pRoutes.test.ts index 4c2ddbffb..ef2180cba 100644 --- a/server/src/routes/__test__/h5pRoutes.test.ts +++ b/server/src/routes/__test__/h5pRoutes.test.ts @@ -15,7 +15,6 @@ describe('[export h5p as html]: GET /api/v1/h5p/:contentId/html', () => { configFile: path.resolve('test', 'data', 'config.json'), librariesPath: path.resolve('test', 'data', `libraries`), temporaryStoragePath: path.resolve('test', 'data', 'tmp'), - runFile: path.resolve('test', 'data', 'run.json'), workingCachePath: path.resolve('test', 'data', 'workingCache'), settingsFile: path.resolve('test', 'data', 'settings.json') }, diff --git a/server/src/routes/__test__/settingsRoutes.test.ts b/server/src/routes/__test__/settingsRoutes.test.ts index c68de26a4..3455e43dd 100644 --- a/server/src/routes/__test__/settingsRoutes.test.ts +++ b/server/src/routes/__test__/settingsRoutes.test.ts @@ -15,7 +15,6 @@ describe('GET /settings', () => { configFile: path.resolve('test', 'data', 'config.json'), librariesPath: path.resolve('test', 'data', `libraries`), temporaryStoragePath: path.resolve('test', 'data', 'tmp'), - runFile: path.resolve('test', 'data', 'run.json'), workingCachePath: path.resolve('test', 'data', 'workingCache'), settingsFile: path.resolve('test', 'data', 'settings.json') }, @@ -46,7 +45,6 @@ describe('PATCH /settings', () => { configFile: path.resolve('test', 'data', 'config.json'), librariesPath: path.resolve('test', 'data', `libraries`), temporaryStoragePath: path.resolve('test', 'data', 'tmp'), - runFile: path.resolve('test', 'data', 'run.json'), workingCachePath: path.resolve('test', 'data', 'workingCache'), settingsFile: path.resolve('test', 'data', 'settings.json') diff --git a/server/src/routes/authRoutes.ts b/server/src/routes/authRoutes.ts index 7bbf6b6e9..677fca4f5 100644 --- a/server/src/routes/authRoutes.ts +++ b/server/src/routes/authRoutes.ts @@ -5,7 +5,10 @@ import proxy from 'express-http-proxy'; export default function (): express.Router { const router = express.Router(); - router.use('/', proxy('http://auth.lumi.education')); + router.use( + '/', + proxy(process.env.LUMI_HOST || 'https://api.lumi.education') + ); return router; } diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index e3bf97ca4..4864607e6 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -51,10 +51,7 @@ export default function ( settingsRoutes(serverConfig, browserWindow, app) ); - router.use( - '/api/v1/run', - runRoutes(serverConfig, h5pEditor, browserWindow) - ); + router.use('/api/run', runRoutes(serverConfig, h5pEditor, browserWindow)); // // Directly serving the library and content files statically speeds up // // loading times and there is no security issue, as Lumi never is a diff --git a/server/src/routes/runRoutes.ts b/server/src/routes/runRoutes.ts index f4dce8983..0d4591c4c 100644 --- a/server/src/routes/runRoutes.ts +++ b/server/src/routes/runRoutes.ts @@ -6,11 +6,19 @@ import User from '../User'; import fs from 'fs-extra'; import path from 'path'; import superagent from 'superagent'; +import proxy from 'express-http-proxy'; import * as H5P from '@lumieducation/h5p-server'; import HtmlExporter from '@lumieducation/h5p-html-exporter'; +import settingsCache from '../settingsCache'; + +import LumiController from '../controllers/LumiController'; + import { io as websocket } from '../websocket'; +import LumiError from '../helpers/LumiError'; + +const runHost = process.env.LUMI_HOST || 'https://lumi.run'; export default function ( serverConfig: IServerConfig, @@ -18,237 +26,117 @@ export default function ( browserWindow: BrowserWindow ): express.Router { const router = express.Router(); + const lumiController = new LumiController( + h5pEditor, + serverConfig, + browserWindow + ); + router.get( - `/`, + '/', async ( req: express.Request, res: express.Response, next: express.NextFunction ) => { try { - const run = await fs.readJSON(serverConfig.runFile); + const { body } = await superagent + .get(`${runHost}/api/v1/run`) + .set('x-auth', settingsCache.getSettings().token || ''); - res.status(200).json(run); + res.status(200).json(body); } catch (error) { - Sentry.captureException(error); - res.status(500).end(); + res.status(error.status || 500).json(error.response?.body); } } ); - router.patch( - '/', + router.delete( + '/:runId', async ( req: express.Request, res: express.Response, next: express.NextFunction ) => { try { - if (req.body) { - await fs.readJSON(serverConfig.runFile); - - await fs.writeJSON(serverConfig.runFile, req.body); + const { body } = await superagent + .delete(`${runHost}/api/v1/run/${req.params.runId}`) + .set('x-auth', settingsCache.getSettings().token); - res.status(200).json(req.body); - } + res.status(200).json(body); } catch (error) { - Sentry.captureException(error); - res.status(500).end(); + res.status(500).json(error); } } ); router.post( - '/upload', + '/consent', async ( req: express.Request, res: express.Response, next: express.NextFunction ) => { - let filePath: string = - req.query.filePath && `${req.query.filePath}`; - if (!filePath) { - const { filePaths } = await dialog.showOpenDialog( - browserWindow, - { - filters: [ - { - extensions: ['h5p'], - name: 'HTML 5 Package' - } - ], - properties: ['openFile'] - } - ); - - filePath = filePaths[0]; - } - - if (!filePath) { - return res.status(499).end(); - } - - let htmlFilePath; - let contentId; - let meta; - - websocket.emit('action', { - type: 'action', - payload: { - type: 'RUN_UPDATE_STATE', - payload: { - showDialog: true, - uploadProgress: { - import: { - state: 'pending' - }, - export: { - state: 'not_started' - }, - upload: { - state: 'not_started', - progress: 0 - } - } - } - } - }); - try { - const buffer = await fs.readFile(filePath); - - const { metadata, parameters } = await h5pEditor.uploadPackage( - buffer, - new User() - ); + const { body } = await superagent + .post(`${runHost}/api/v1/run/consent`) + .set('x-auth', settingsCache.getSettings().token); - meta = metadata; + res.status(200).json(body); + } catch (error) { + res.status(500).json(error); + } + } + ); - contentId = await h5pEditor.saveOrUpdateContent( - undefined, - parameters, - metadata, - getUbernameFromH5pJson(metadata), - new User() - ); + router.post( + '/', + async ( + req: express.Request, + res: express.Response, + next: express.NextFunction + ) => { + let filePath: string = + req.query.filePath && `${req.query.filePath}`; - websocket.emit('action', { - type: 'action', - payload: { - type: 'RUN_UPDATE_STATE', - payload: { - showDialog: true, - uploadProgress: { - import: { - state: 'success' - }, - export: { - state: 'pending' - }, - upload: { - state: 'not_started', - progress: 0 + const contentId = req.body.contentId; + + if (!contentId) { + if (!filePath) { + const { filePaths } = await dialog.showOpenDialog( + browserWindow, + { + filters: [ + { + extensions: ['h5p'], + name: 'HTML 5 Package' } - } + ], + properties: ['openFile'] } - } - }); - } catch (error) { - Sentry.captureException(error); - return websocket.emit('action', { - type: 'action', - payload: { - type: 'RUN_UPDATE_STATE', - payload: { - showDialog: true, - uploadProgress: { - import: { - state: 'error' - }, - export: { - state: 'not_started' - }, - upload: { - state: 'not_started', - progress: 0 - } - } - } - } - }); - } + ); - try { - const htmlExporter = new HtmlExporter( - h5pEditor.libraryStorage, - h5pEditor.contentStorage, - h5pEditor.config, - `${__dirname}/../../../h5p/core`, - `${__dirname}/../../../h5p/editor` - ); + filePath = filePaths[0]; + } - const html = await htmlExporter.createSingleBundle( - contentId, - new User() - ); + if (!filePath) { + return res.status(499).end(); + } + } - htmlFilePath = path.join( - serverConfig.workingCachePath, - `${contentId}.html` + if (contentId) { + filePath = path.join( + serverConfig.temporaryStoragePath, + `${contentId}.h5p` ); - await fs.writeFileSync(htmlFilePath, html); - - websocket.emit('action', { - type: 'action', - payload: { - type: 'RUN_UPDATE_STATE', - payload: { - showDialog: true, - uploadProgress: { - import: { - state: 'success' - }, - export: { - state: 'success' - }, - upload: { - state: 'pending', - progress: 0 - } - } - } - } - }); - } catch (error) { - Sentry.captureException(error); - return websocket.emit('action', { - type: 'action', - payload: { - type: 'RUN_UPDATE_STATE', - payload: { - showDialog: true, - uploadProgress: { - import: { - state: 'success' - }, - export: { - state: 'error' - }, - upload: { - state: 'not_started', - progress: 0 - } - } - } - } - }); + await lumiController.export(`${contentId}`, filePath); } - let run; + try { const response = await superagent - .post('http://lumi.run') - .set('x-auth', 'c598f9799dfa05ea02156f847530fbea') - .attach('content', htmlFilePath) + .post(`${runHost}/api/v1/run`) + .set('x-auth', settingsCache.getSettings().token) + .attach('h5p', filePath) .on('progress', (event) => { websocket.emit('action', { type: 'action', @@ -257,128 +145,24 @@ export default function ( payload: { showDialog: true, uploadProgress: { - import: { - state: 'success' - }, - export: { - state: 'success' - }, - upload: { - state: 'pending', - progress: - event.loaded / - (event.total / 100) - } + state: 'pending', + progress: + event.loaded / (event.total / 100) } } } }); }); - const newEntry = { - title: meta.title, - mainLibrary: meta.mainLibrary, - id: response.body.id, - secret: response.body.secret - }; - - run = await fs.readJSON(serverConfig.runFile); - run.runs = [...run.runs, newEntry]; - await fs.writeJSON(serverConfig.runFile, run); - - await fs.remove(htmlFilePath); - await fs.remove( - path.join(serverConfig.workingCachePath, `${contentId}`) - ); - - websocket.emit('action', { - type: 'action', - payload: { - type: 'RUN_UPDATE_STATE', - payload: { - showDialog: true, - uploadProgress: { - import: { - state: 'success' - }, - export: { - state: 'success' - }, - upload: { - state: 'success', - progress: 100 - } - } - } - } - }); - } catch (error) { - Sentry.captureException(error); - return websocket.emit('action', { - type: 'action', - payload: { - type: 'RUN_UPDATE_STATE', - payload: { - showDialog: true, - uploadProgress: { - import: { - state: 'success' - }, - export: { - state: 'success' - }, - upload: { - state: 'error', - progress: 0 - } - } - } - } - }); - } - - res.status(200).json(run); - } - ); - - router.delete( - '/:id', - async ( - req: express.Request, - res: express.Response, - next: express.NextFunction - ) => { - try { - const response = await superagent.delete( - `http://lumi.run/${req.params.id}?secret=${req.query.secret}` - ); - - const run = await fs.readJSON(serverConfig.runFile); - run.runs = run.runs.filter((r) => r.id !== req.params.id); - await fs.writeJSON(serverConfig.runFile, run); - - res.status(200).json(response); - } catch (error) { - if (error.status === 404) { - const run = await fs.readJSON(serverConfig.runFile); - run.runs = run.runs.filter((r) => r.id !== req.params.id); - await fs.writeJSON(serverConfig.runFile, run); + if (contentId) { + await fs.unlink(filePath); } - Sentry.captureException(error); - res.status(500).end(); + res.status(200).json(response.body); + } catch (error) { + res.status(error.status || 500).json(error.response?.body); } } ); return router; } - -function getUbernameFromH5pJson(h5pJson: H5P.IContentMetadata): string { - const library = (h5pJson.preloadedDependencies || []).find( - (dependency) => dependency.machineName === h5pJson.mainLibrary - ); - if (!library) { - return ''; - } - return H5P.LibraryName.toUberName(library, { useWhitespace: true }); -} diff --git a/server/src/serverConfig.ts b/server/src/serverConfig.ts index e235e90f9..7044dc102 100644 --- a/server/src/serverConfig.ts +++ b/server/src/serverConfig.ts @@ -5,7 +5,6 @@ export default (userData: string): IServerConfig => { return { librariesPath: path.join(userData, 'libraries'), cache: path.join(userData, 'store.json'), - runFile: path.join(userData, 'run.json'), temporaryStoragePath: path.join(userData, 'tmp'), workingCachePath: path.join(userData, 'workingCache'), configFile: path.join(userData, 'config.json'), diff --git a/server/src/settingsCache.ts b/server/src/settingsCache.ts index 801b07a1c..44c5c1fa2 100644 --- a/server/src/settingsCache.ts +++ b/server/src/settingsCache.ts @@ -1,10 +1,12 @@ interface ISettingsState { autoUpdates: boolean; bugTracking: boolean; + email: string; firstOpen: boolean; language: string; lastVersion: string; privacyPolicyConsent: boolean; + token: string; usageStatistics: boolean; }