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'
)

View File

@@ -48,10 +48,16 @@ body {
color: var(--sn-stylekit-info-contrast-color);
}
*:focus {outline:0;}
h1 {
font-size: var(--sn-stylekit-font-size-h1);
}
button:focus {
outline:0;
h2 {
font-size: var(--sn-stylekit-font-size-h2);
}
h3 {
font-size: var(--sn-stylekit-font-size-h3);
}
input, button, select, textarea {

View File

@@ -63,9 +63,16 @@
font-weight: normal;
font-size: var(--sn-stylekit-font-size-h3);
border: none;
border-style: solid;
border-color: transparent;
width: 100%;
position: relative;
&:focus {
outline: 0;
border-color: var(--sn-stylekit-info-color);
border-width: 1px;
}
}
#search-clear-button {

View File

@@ -0,0 +1,38 @@
[data-reach-dialog-overlay] {
z-index: $z-index-modal;
background: none;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
overflow: unset;
}
[data-reach-dialog-overlay]::before {
background-color: var(--sn-stylekit-contrast-background-color);
content: "";
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
opacity: 0.75;
}
[data-reach-dialog-content] {
padding: 0;
margin: 0;
position: relative;
overflow: unset;
flex-basis: 0;
}
[data-reach-dialog-content] .sk-modal-content,
[data-reach-dialog-content] .sn-component,
[data-reach-dialog-content] .sk-panel {
height: 100%;
}
[data-reach-alert-dialog-content] {
width: auto;
}

View File

@@ -0,0 +1,48 @@
.sessions-modal {
h2, ul, p {
margin: 0;
padding: 0;
}
h2 {
font-size: var(--sn-stylekit-font-size-h2);
}
ul {
grid-column: 1 / 3;
display: grid;
gap: 16px;
grid-gap: 16px;
}
li {
display: grid;
grid-template-columns: 1fr max-content;
border-bottom: 1px solid var(--sn-stylekit-border-color);
padding-bottom: 16px;
grid-column-gap: 12px;
column-gap: 12px;
}
li:last-of-type {
border: none;
padding-bottom: 0;
}
li > * {
grid-column: 1;
}
li button {
grid-column: 2;
grid-row: 1 / span 3;
align-self: center;
}
.sn-component .sk-panel-content {
padding-bottom: 1.6rem;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
grid-gap: 8px;
gap: 8px;
}
}
.sessions-modal-refreshing {
grid-column: 2;
font-weight: normal;
}

View File

@@ -77,6 +77,12 @@ button.sk-button {
border: none;
}
a {
a, .sk-a {
background: none;
border: none;
color: var(--sn-stylekit-info-color);
}
button.sk-a {
min-height: 24px;
}

View File

@@ -9,3 +9,5 @@
@import "lock-screen";
@import "stylekit-sub";
@import "ionicons";
@import "reach-sub";
@import "sessions-modal";

View File

@@ -161,9 +161,12 @@
span(ng-if='self.state.syncPercentage')
| ({{self.state.syncPercentage}})
.sk-panel-row
a.sk-a.info.sk-panel-row.condensed(
ng-click="self.openSessionsModal()"
ng-if="self.state.showSessions"
) Review active sessions
a.sk-a.info.sk-panel-row.condensed(
ng-click="self.openPasswordWizard()"
ng-if="!self.state.showBetaWarning"
) Change Password
a.sk-a.info.sk-panel-row.condensed(
ng-click="self.openPrivilegesModal('')",