feat: (wip) sessions management

This commit is contained in:
Baptiste Grob
2020-12-16 17:27:26 +01:00
parent da6858fa45
commit 2c0f215409
20 changed files with 784 additions and 131 deletions

View File

@@ -55,9 +55,9 @@ import { trusted } from './filters';
import { isDev } from './utils';
import { BrowserBridge } from './services/browserBridge';
import { startErrorReporting } from './services/errorReporting';
import { alertDialog } from './services/alertService';
import { StartApplication } from './startApplication';
import { Bridge } from './services/bridge';
import { SessionsModalDirective } from './directives/views/sessionsModal';
const startApplication: StartApplication = async function startApplication(
defaultSyncServerHost: string,
@@ -122,7 +122,8 @@ const startApplication: StartApplication = async function startApplication(
)
.directive('revisionPreviewModal', () => new RevisionPreviewModal())
.directive('historyMenu', () => new HistoryMenu())
.directive('syncResolutionMenu', () => new SyncResolutionMenu());
.directive('syncResolutionMenu', () => new SyncResolutionMenu())
.directive('sessionsModal', SessionsModalDirective);
// Filters
angular.module('app').filter('trusted', ['$sce', trusted]);

View File

@@ -69,6 +69,7 @@ type AccountMenuState = {
syncInProgress: boolean;
syncError: string;
syncPercentage: string;
showSessions: boolean;
}
class AccountMenuCtrl extends PureViewCtrl<{}, AccountMenuState> {
@@ -101,6 +102,7 @@ class AccountMenuCtrl extends PureViewCtrl<{}, AccountMenuState> {
mutable: {},
showBetaWarning: false,
errorReportingEnabled: !storage.get(StorageKey.DisableErrorReporting),
showSessions: this.appState.enableUnfinishedFeatures,
} as AccountMenuState;
}
@@ -320,6 +322,11 @@ class AccountMenuCtrl extends PureViewCtrl<{}, AccountMenuState> {
this.application!.presentPasswordWizard(PasswordWizardType.ChangePassword);
}
openSessionsModal() {
this.close();
this.appState.openSessionsModal();
}
async openPrivilegesModal() {
const run = () => {
this.application!.presentPrivilegesManagementModal();

View File

@@ -0,0 +1,251 @@
import { AppState } from '@/ui_models/app_state';
import { PureViewCtrl } from '@/views';
import { SNApplication, RemoteSession, UuidString } from '@standardnotes/snjs';
import { autorun, IAutorunOptions, IReactionPublic } from 'mobx';
import { render, FunctionComponent } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import { Dialog } from '@reach/dialog';
import { Alert } from '@reach/alert';
import {
AlertDialog,
AlertDialogDescription,
AlertDialogLabel,
} from '@reach/alert-dialog';
function useAutorun(view: (r: IReactionPublic) => any, opts?: IAutorunOptions) {
useEffect(() => autorun(view, opts), []);
}
function useSessions(
application: SNApplication
): [
RemoteSession[],
() => void,
boolean,
(uuid: UuidString) => Promise<void>,
string
] {
const [sessions, setSessions] = useState<RemoteSession[]>([]);
const [lastRefreshDate, setLastRefreshDate] = useState(Date.now());
const [refreshing, setRefreshing] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
(async () => {
setRefreshing(true);
const response = await application.getSessions();
if ('error' in response) {
if (response.error?.message) {
setErrorMessage(response.error.message);
} else {
setErrorMessage('An unknown error occured while loading sessions.');
}
} else {
const sessions = response as RemoteSession[];
setSessions(sessions);
setErrorMessage('');
}
setRefreshing(false);
})();
}, [lastRefreshDate]);
function refresh() {
setLastRefreshDate(Date.now());
}
async function revokeSession(uuid: UuidString) {
let sessionsBeforeRevoke = sessions;
setSessions(sessions.filter((session) => session.uuid !== uuid));
const response = await application.revokeSession(uuid);
if ('error' in response) {
if (response.error?.message) {
setErrorMessage(response.error?.message);
} else {
setErrorMessage('An unknown error occured while revoking the session.');
}
setSessions(sessionsBeforeRevoke);
}
}
return [sessions, refresh, refreshing, revokeSession, errorMessage];
}
const SessionsModal: FunctionComponent<{
appState: AppState;
application: SNApplication;
}> = ({ appState, application }) => {
const close = () => appState.closeSessionsModal();
const [
sessions,
refresh,
refreshing,
revokeSession,
errorMessage,
] = useSessions(application);
const [revokingSessionUuid, setRevokingSessionUuid] = useState('');
const closeRevokeSessionAlert = () => setRevokingSessionUuid('');
const cancelRevokeRef = useRef<HTMLButtonElement>();
const formatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
hour: 'numeric',
minute: 'numeric',
});
return (
<>
<Dialog onDismiss={close}>
<div className="sk-modal-content sessions-modal">
<div class="sn-component">
<div class="sk-panel">
<div class="sk-panel-header">
<div class="sk-panel-header-title">Active Sessions</div>
<div className="buttons">
<button
class="sk-a close-button info"
disabled={refreshing}
onClick={refresh}
>
Refresh
</button>
<button class="sk-a close-button info" onClick={close}>
Close
</button>
</div>
</div>
<div class="sk-panel-content">
{refreshing ? (
<>
<div class="sk-spinner small info"></div>
<h2 className="sk-p sessions-modal-refreshing">
Loading sessions
</h2>
</>
) : (
<>
{errorMessage && (
<Alert className="sk-p bold">{errorMessage}</Alert>
)}
{sessions.length > 0 && (
<ul>
{sessions.map((session) => (
<li>
<h2>{session.device_info}</h2>
{session.current ? (
<span className="info bold">Current session</span>
) : (
<>
<p>
Signed in on{' '}
{formatter.format(session.updated_at)}
</p>
<button
className="sk-button danger sk-label"
onClick={() =>
setRevokingSessionUuid(session.uuid)
}
>
<span>Revoke</span>
</button>
</>
)}
</li>
))}
</ul>
)}
</>
)}
</div>
</div>
</div>
</div>
</Dialog>
{revokingSessionUuid && (
<AlertDialog leastDestructiveRef={cancelRevokeRef}>
<div className="sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-content">
<div className="sk-panel-section">
<AlertDialogLabel className="sk-h3 sk-panel-section-title">
Revoke this session?
</AlertDialogLabel>
<AlertDialogDescription className="sk-panel-row">
<p>
The associated app will not be able to sync unless you
sign in again.
</p>
</AlertDialogDescription>
<div className="sk-panel-row">
<div className="sk-button-group">
<button
className="sk-button neutral sk-label"
ref={cancelRevokeRef}
onClick={closeRevokeSessionAlert}
>
<span>Cancel</span>
</button>
<button
className="sk-button danger sk-label"
onClick={() => {
closeRevokeSessionAlert();
revokeSession(revokingSessionUuid);
}}
>
<span>Revoke</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</AlertDialog>
)}
</>
);
};
const Sessions: FunctionComponent<{
appState: AppState;
application: SNApplication;
}> = ({ appState, application }) => {
const [showModal, setShowModal] = useState(false);
useAutorun(() => setShowModal(appState.isSessionsModalVisible));
if (showModal) {
return <SessionsModal application={application} appState={appState} />;
} else {
return null;
}
};
class SessionsModalCtrl extends PureViewCtrl<{}, {}> {
/* @ngInject */
constructor(private $element: JQLite, $timeout: ng.ITimeoutService) {
super($timeout);
this.$element = $element;
}
$onChanges() {
render(
<Sessions appState={this.appState} application={this.application} />,
this.$element[0]
);
}
}
export function SessionsModalDirective() {
return {
controller: SessionsModalCtrl,
bindToController: true,
scope: {
application: '=',
},
};
}

View File

@@ -1,8 +1,10 @@
//= require_tree ./app
// css
import '@reach/dialog/styles.css';
import 'sn-stylekit/dist/stylekit.css';
import '../stylesheets/index.css.scss';
// import '../stylesheets/_reach-sub.scss';
// Vendor
import 'angular';

View File

@@ -87,7 +87,7 @@ export class ArchiveManager {
scriptTag.async = false;
const headTag = document.getElementsByTagName('head')[0];
headTag.appendChild(scriptTag);
return new Promise((resolve) => {
return new Promise<void>((resolve) => {
scriptTag.onload = () => {
this.zip.workerScriptsPath = 'assets/zip/';
resolve();

View File

@@ -12,6 +12,8 @@
"newLine": "lf",
"declarationDir": "../../../dist/@types",
"baseUrl": ".",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": {
"%/*": ["../templates/*"],
"@/*": ["./*"],

View File

@@ -1,4 +1,4 @@
import { isDesktopApplication } from '@/utils';
import { isDesktopApplication, isDev } from '@/utils';
import pull from 'lodash/pull';
import {
ProtectedAction,
@@ -11,7 +11,7 @@ import {
PayloadSource,
DeinitSource,
UuidString,
SyncOpStatus
SyncOpStatus,
} from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application';
import { Editor } from '@/ui_models/editor';
@@ -27,14 +27,14 @@ export enum AppStateEvent {
EndedBackupDownload,
WindowDidFocus,
WindowDidBlur,
};
}
export enum EventSource {
UserInteraction,
Script,
};
}
type ObserverCallback = (event: AppStateEvent, data?: any) => Promise<void>
type ObserverCallback = (event: AppStateEvent, data?: any) => Promise<void>;
const SHOW_BETA_WARNING_KEY = 'show_beta_warning';
@@ -76,7 +76,8 @@ export class SyncState {
this.errorMessage = status.error?.message;
this.inProgress = status.syncInProgress;
const stats = status.getStats();
const completionPercentage = stats.uploadCompletionCount === 0
const completionPercentage =
stats.uploadCompletionCount === 0
? 0
: stats.uploadCompletionCount / stats.uploadTotalCount;
@@ -92,6 +93,9 @@ export class SyncState {
}
export class AppState {
readonly enableUnfinishedFeatures =
isDev || location.host.includes('app-dev.standardnotes.org');
$rootScope: ng.IRootScopeService;
$timeout: ng.ITimeoutService;
application: WebApplication;
@@ -106,31 +110,36 @@ export class AppState {
showBetaWarning = false;
readonly actionsMenu = new ActionsMenuState();
readonly sync = new SyncState();
isSessionsModalVisible = true;
/* @ngInject */
constructor(
$rootScope: ng.IRootScopeService,
$timeout: ng.ITimeoutService,
application: WebApplication,
private bridge: Bridge,
private bridge: Bridge
) {
this.$timeout = $timeout;
this.$rootScope = $rootScope;
this.application = application;
makeObservable(this, {
showBetaWarning: observable,
isSessionsModalVisible: observable,
enableBetaWarning: action,
disableBetaWarning: action,
openSessionsModal: action,
closeSessionsModal: action,
});
this.addAppEventObserver();
this.streamNotesAndTags();
this.onVisibilityChange = () => {
const visible = document.visibilityState === "visible";
const visible = document.visibilityState === 'visible';
const event = visible
? AppStateEvent.WindowDidFocus
: AppStateEvent.WindowDidBlur;
this.notifyEvent(event);
}
};
this.registerVisibilityObservers();
this.determineBetaWarningValue();
}
@@ -153,6 +162,14 @@ export class AppState {
this.onVisibilityChange = undefined;
}
openSessionsModal() {
this.isSessionsModalVisible = true;
}
closeSessionsModal() {
this.isSessionsModalVisible = false;
}
disableBetaWarning() {
this.showBetaWarning = false;
localStorage.setItem(SHOW_BETA_WARNING_KEY, 'false');
@@ -188,10 +205,10 @@ export class AppState {
async createEditor(title?: string) {
const activeEditor = this.getActiveEditor();
const activeTagUuid = this.selectedTag
? this.selectedTag.isSmartTag()
? undefined
: this.selectedTag.uuid
: undefined;
? this.selectedTag.isSmartTag()
? undefined
: this.selectedTag.uuid
: undefined;
if (!activeEditor || this.multiEditorEnabled) {
this.application.editorGroup.createEditor(
@@ -216,10 +233,13 @@ export class AppState {
}
await this.notifyEvent(AppStateEvent.ActiveEditorChanged);
};
if (note && note.safeContent.protected &&
await this.application.privilegesService!.actionRequiresPrivilege(
if (
note &&
note.safeContent.protected &&
(await this.application.privilegesService!.actionRequiresPrivilege(
ProtectedAction.ViewProtectedNotes
)) {
))
) {
return new Promise((resolve) => {
this.application.presentPrivilegesModal(
ProtectedAction.ViewProtectedNotes,
@@ -267,8 +287,8 @@ export class AppState {
async (items, source) => {
/** Close any editors for deleted/trashed/archived notes */
if (source === PayloadSource.PreSyncSave) {
const notes = items.filter((candidate) =>
candidate.content_type === ContentType.Note
const notes = items.filter(
(candidate) => candidate.content_type === ContentType.Note
) as SNNote[];
for (const note of notes) {
const editor = this.editorForNote(note);
@@ -285,7 +305,9 @@ export class AppState {
}
}
if (this.selectedTag) {
const matchingTag = items.find((candidate) => candidate.uuid === this.selectedTag!.uuid);
const matchingTag = items.find(
(candidate) => candidate.uuid === this.selectedTag!.uuid
);
if (matchingTag) {
this.selectedTag = matchingTag as SNTag;
}
@@ -319,9 +341,12 @@ export class AppState {
this.rootScopeCleanup1 = this.$rootScope.$on('window-lost-focus', () => {
this.notifyEvent(AppStateEvent.WindowDidBlur);
});
this.rootScopeCleanup2 = this.$rootScope.$on('window-gained-focus', () => {
this.notifyEvent(AppStateEvent.WindowDidFocus);
});
this.rootScopeCleanup2 = this.$rootScope.$on(
'window-gained-focus',
() => {
this.notifyEvent(AppStateEvent.WindowDidFocus);
}
);
} else {
/* Tab visibility listener, web only */
document.addEventListener('visibilitychange', this.onVisibilityChange);
@@ -341,7 +366,7 @@ export class AppState {
* Timeout is particullary important so we can give all initial
* controllers a chance to construct before propogting any events *
*/
return new Promise((resolve) => {
return new Promise<void>((resolve) => {
this.$timeout(async () => {
for (const callback of this.observers) {
await callback(eventName, data);
@@ -357,20 +382,17 @@ export class AppState {
}
const previousTag = this.selectedTag;
this.selectedTag = tag;
this.notifyEvent(
AppStateEvent.TagChanged,
{
tag: tag,
previousTag: previousTag
}
);
this.notifyEvent(AppStateEvent.TagChanged, {
tag: tag,
previousTag: previousTag,
});
}
/** Returns the tags that are referncing this note */
public getNoteTags(note: SNNote) {
return this.application.referencingForItem(note).filter((ref) => {
return ref.content_type === ContentType.Tag;
}) as SNTag[]
}) as SNTag[];
}
public getSelectedTag() {
@@ -378,32 +400,21 @@ export class AppState {
}
panelDidResize(name: string, collapsed: boolean) {
this.notifyEvent(
AppStateEvent.PanelResized,
{
panel: name,
collapsed: collapsed
}
);
this.notifyEvent(AppStateEvent.PanelResized, {
panel: name,
collapsed: collapsed,
});
}
editorDidFocus(eventSource: EventSource) {
this.notifyEvent(
AppStateEvent.EditorFocused,
{ eventSource: eventSource }
);
this.notifyEvent(AppStateEvent.EditorFocused, { eventSource: eventSource });
}
beganBackupDownload() {
this.notifyEvent(
AppStateEvent.BeganBackupDownload
);
this.notifyEvent(AppStateEvent.BeganBackupDownload);
}
endedBackupDownload(success: boolean) {
this.notifyEvent(
AppStateEvent.EndedBackupDownload,
{ success: success }
);
this.notifyEvent(AppStateEvent.EndedBackupDownload, { success: success });
}
}

View File

@@ -69,7 +69,7 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
if (!this.$timeout) {
return;
}
return new Promise((resolve) => {
return new Promise<void>((resolve) => {
this.stateTimeout = this.$timeout(() => {
/**
* State changes must be *inside* the timeout block for them to be affected in the UI

View File

@@ -24,3 +24,6 @@
symbol#layers-sharp.ionicon(viewbox="0 0 512 512")
path(d="M480 150L256 48 32 150l224 104 224-104zM255.71 392.95l-144.81-66.2L32 362l224 102 224-102-78.69-35.3-145.6 66.25z")
path(d="M480 256l-75.53-33.53L256.1 290.6l-148.77-68.17L32 256l224 102 224-102z")
sessions-modal(
application='self.application'
)