feat: implement UI for logging out (#638)

* feat: implement UI for logging out

* feat: use old style dialogs for logout confirmation

* feat: implement manage sessions

* feat: implement session logout success dialog

* feat: use snjs alert for revoking sessions confirmation

* fix: make OtherSessionsLogout easier to read
This commit is contained in:
Gorjan Petrovski 2021-09-21 19:01:32 +02:00 committed by GitHub
parent a9610fdbc6
commit 77525a56cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 342 additions and 70 deletions

View file

@ -2,19 +2,22 @@ import { FunctionComponent } from 'preact';
const baseClass = `rounded px-4 py-1.75 font-bold text-sm fit-content`;
const normalClass = `${baseClass} bg-default color-text border-solid border-gray-300 border-1 \
focus:bg-contrast hover:bg-contrast`;
const primaryClass = `${baseClass} no-border bg-info color-info-contrast hover:brightness-130 \
focus:brightness-130`;
type ButtonType = 'normal' | 'primary' | 'danger';
const buttonClasses: { [type in ButtonType]: string } = {
normal: `${baseClass} bg-default color-text border-solid border-gray-300 border-1 focus:bg-contrast hover:bg-contrast`,
primary: `${baseClass} no-border bg-info color-info-contrast hover:brightness-130 focus:brightness-130`,
danger: `${baseClass} bg-default color-danger border-solid border-gray-300 border-1 focus:bg-contrast hover:bg-contrast`,
};
export const Button: FunctionComponent<{
className?: string;
type: 'normal' | 'primary';
type: ButtonType;
label: string;
onClick: () => void;
disabled?: boolean;
}> = ({ type, label, className = '', onClick, disabled = false }) => {
const buttonClass = type === 'primary' ? primaryClass : normalClass;
const buttonClass = buttonClasses[type];
const cursorClass = disabled ? 'cursor-default' : 'cursor-pointer';
return (

View file

@ -26,7 +26,7 @@ const ConfirmSignoutModal = observer(({ application, appState }: Props) => {
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false);
const cancelRef = useRef<HTMLButtonElement>();
function close() {
function closeDialog() {
appState.accountMenu.setSigningOut(false);
}
@ -37,7 +37,7 @@ const ConfirmSignoutModal = observer(({ application, appState }: Props) => {
}, [appState.accountMenu.signingOut, application.bridge]);
return (
<AlertDialog onDismiss={close} leastDestructiveRef={cancelRef}>
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
<div className="sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
@ -83,7 +83,7 @@ const ConfirmSignoutModal = observer(({ application, appState }: Props) => {
<button
className="sn-button small neutral"
ref={cancelRef}
onClick={close}
onClick={closeDialog}
>
Cancel
</button>
@ -95,7 +95,7 @@ const ConfirmSignoutModal = observer(({ application, appState }: Props) => {
} else {
application.signOut();
}
close();
closeDialog();
}}
>
{application.hasAccount()

View file

@ -0,0 +1,35 @@
import { ComponentChildren, FunctionComponent } from 'preact';
import {
AlertDialog,
AlertDialogDescription,
AlertDialogLabel,
} from '@reach/alert-dialog';
import { useRef } from 'preact/hooks';
export const ConfirmationDialog: FunctionComponent<{
title: string | ComponentChildren;
}> = ({ title, children }) => {
const ldRef = useRef<HTMLButtonElement>();
return (
<AlertDialog leastDestructiveRef={ldRef}>
{/* sn-component is focusable by default, but doesn't stretch to child width
resulting in a badly focused dialog. Utility classes are not available
at the sn-component level, only below it. tabIndex -1 disables focus
and enables it on the child component */}
<div tabIndex={-1} className="sn-component">
<div
tabIndex={0}
className="max-w-89 bg-default rounded shadow-overlay focus:padded-ring-info px-9 py-9 flex flex-col items-center"
>
<AlertDialogLabel>{title}</AlertDialogLabel>
<div className="min-h-2" />
<AlertDialogDescription className="flex flex-col items-center">
{children}
</AlertDialogDescription>
</div>
</div>
</AlertDialog>
);
};

View file

@ -0,0 +1,80 @@
import { useRef, useState } from 'preact/hooks';
import {
AlertDialog,
AlertDialogDescription,
AlertDialogLabel,
} from '@reach/alert-dialog';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
type Props = {
application: WebApplication;
appState: AppState;
};
export const OtherSessionsLogoutContainer = observer((props: Props) => {
if (!props.appState.accountMenu.otherSessionsLogOut) {
return null;
}
return <ConfirmOtherSessionsLogout {...props} />;
});
const ConfirmOtherSessionsLogout = observer(
({ application, appState }: Props) => {
const cancelRef = useRef<HTMLButtonElement>();
function closeDialog() {
appState.accountMenu.setOtherSessionsLogout(false);
}
return (
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
<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 capitalize">
End all other sessions?
</AlertDialogLabel>
<AlertDialogDescription className="sk-panel-row">
<p className="color-foreground">
This action will sign out all other devices signed into your account,
and remove your data from those devices when they next regain connection
to the internet. You may sign back in on those devices at any time.
</p>
</AlertDialogDescription>
<div className="flex my-1 mt-4">
<button
className="sn-button small neutral"
ref={cancelRef}
onClick={closeDialog}
>
Cancel
</button>
<button
className="sn-button small danger ml-2"
onClick={() => {
application.revokeAllOtherSessions();
closeDialog();
application.alertService.alert(
"You have successfully revoked your sessions from other devices.",
undefined,
"Finish"
);
}}
>
End Sessions
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</AlertDialog>
);
}
);

View file

@ -26,12 +26,12 @@ type Session = RemoteSession & {
function useSessions(
application: SNApplication
): [
Session[],
() => void,
boolean,
(uuid: UuidString) => Promise<void>,
string
] {
Session[],
() => void,
boolean,
(uuid: UuidString) => Promise<void>,
string
] {
const [sessions, setSessions] = useState<Session[]>([]);
const [lastRefreshDate, setLastRefreshDate] = useState(Date.now());
const [refreshing, setRefreshing] = useState(true);
@ -240,7 +240,7 @@ const SessionsModal: FunctionComponent<{
);
};
const Sessions: FunctionComponent<{
export const Sessions: FunctionComponent<{
appState: AppState;
application: WebApplication;
}> = observer(({ appState, application }) => {

View file

@ -7,9 +7,11 @@ import { PreferencesMenu } from './PreferencesMenu';
import { PreferencesMenuView } from './PreferencesMenuView';
import { WebApplication } from '@/ui_models/application';
import { MfaProps } from './panes/two-factor-auth/MfaProps';
import { AppState } from '@/ui_models/app_state';
interface PreferencesProps extends MfaProps {
application: WebApplication;
appState: AppState;
closePreferences: () => void;
}
@ -20,7 +22,12 @@ const PaneSelector: FunctionComponent<
case 'general':
return null;
case 'account':
return <AccountPreferences application={props.application} />;
return (
<AccountPreferences
application={props.application}
appState={props.appState}
/>
);
case 'appearance':
return null;
case 'security':

View file

@ -2,9 +2,10 @@ import { FunctionComponent } from 'preact';
import { observer } from 'mobx-react-lite';
import { WebApplication } from '@/ui_models/application';
import { PreferencesView } from './PreferencesView';
import { AppState } from '@/ui_models/app_state';
export interface PreferencesViewWrapperProps {
appState: { preferences: { isOpen: boolean; closePreferences: () => void } };
appState: AppState;
application: WebApplication;
}
@ -18,6 +19,7 @@ export const PreferencesViewWrapper: FunctionComponent<PreferencesViewWrapperPro
<PreferencesView
closePreferences={() => appState.preferences.closePreferences()}
application={application}
appState={appState}
mfaProvider={application}
userProvider={application}
/>

View file

@ -0,0 +1,24 @@
import { FunctionComponent } from 'preact';
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
const HorizontalLine: FunctionComponent<{ index: number; length: number }> = ({
index,
length,
}) => (index < length - 1 ? <HorizontalSeparator classes="my-4" /> : null);
export const PreferencesGroup: FunctionComponent = ({ children }) => (
<div className="bg-default border-1 border-solid rounded border-gray-300 px-6 py-6 flex flex-col">
{Array.isArray(children)
? children
.filter(
(child) => child != undefined && child !== '' && child !== false
)
.map((child, i, arr) => (
<>
{child}
<HorizontalLine index={i} length={arr.length} />
</>
))
: children}
</div>
);

View file

@ -1,43 +1,18 @@
import { FunctionComponent } from 'preact';
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
const HorizontalLine: FunctionComponent<{ index: number; length: number }> = ({
index,
length,
}) => (index < length - 1 ? <HorizontalSeparator classes="my-4" /> : null);
export const PreferencesSegment: FunctionComponent = ({ children }) => (
<div className="flex flex-col">{children}</div>
);
export const PreferencesGroup: FunctionComponent = ({ children }) => (
<div className="bg-default border-1 border-solid rounded border-gray-300 px-6 py-6 flex flex-col">
{Array.isArray(children)
? children
.filter(
(child) => child != undefined && child !== '' && child !== false
)
.map((child, i, arr) => (
<>
{child}
<HorizontalLine index={i} length={arr.length} />
</>
))
: children}
</div>
);
export const PreferencesPane: FunctionComponent = ({ children }) => (
<div className="color-black flex-grow flex flex-row overflow-y-auto min-h-0">
<div className="flex-grow flex flex-col py-6 items-center">
<div className="w-125 max-w-125 flex flex-col">
{children != undefined && Array.isArray(children)
? children.map((child, idx, arr) => (
<>
{child}
{idx < arr.length - 1 ? <div className="min-h-3" /> : undefined}
</>
))
? children
.filter((child) => child != undefined)
.map((child) => (
<>
{child}
<div className="min-h-3" />
</>
))
: children}
</div>
</div>

View file

@ -0,0 +1,5 @@
import { FunctionComponent } from 'preact';
export const PreferencesSegment: FunctionComponent = ({ children }) => (
<div className="flex flex-col">{children}</div>
);

View file

@ -1,3 +1,5 @@
export * from './Content';
export * from './MenuItem';
export * from './PreferencesPane';
export * from './PreferencesGroup';
export * from './PreferencesSegment';

View file

@ -1,17 +1,28 @@
import { Sync, SubscriptionWrapper, Credentials } from '@/preferences/panes/account';
import {
Sync,
SubscriptionWrapper,
Credentials,
LogOutWrapper,
} from '@/preferences/panes/account';
import { PreferencesPane } from '@/preferences/components';
import { observer } from 'mobx-react-lite';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
type Props = {
application: WebApplication;
}
export const AccountPreferences = observer(({application}: Props) => {
return (
<PreferencesPane>
<Credentials application={application} />
<Sync application={application} />
<SubscriptionWrapper application={application} />
</PreferencesPane>
);
});
appState: AppState;
};
export const AccountPreferences = observer(
({ application, appState }: Props) => {
return (
<PreferencesPane>
<Credentials application={application} />
<Sync application={application} />
<SubscriptionWrapper application={application} />
<LogOutWrapper application={application} appState={appState} />
</PreferencesPane>
);
}
);

View file

@ -0,0 +1,103 @@
import { Button } from '@/components/Button';
import { ConfirmSignoutContainer } from '@/components/ConfirmSignoutModal';
import { OtherSessionsLogoutContainer } from '@/components/OtherSessionsLogout';
import {
PreferencesGroup,
PreferencesSegment,
Subtitle,
Text,
Title,
} from '@/preferences/components';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
const LogOutView: FunctionComponent<{
application: WebApplication;
appState: AppState;
}> = observer(({ application, appState }) => {
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<Title>Log out</Title>
<div className="min-h-2" />
<Subtitle>Other devices</Subtitle>
<Text>Want to log out on all devices except this one?</Text>
<div className="min-h-3" />
<div className="flex flex-row">
<Button
className="mr-3"
type="normal"
label="Log out other sessions"
onClick={() => {
appState.accountMenu.setOtherSessionsLogout(true);
}}
/>
<Button type="normal" label="Manage sessions" onClick={() => appState.openSessionsModal()} />
</div>
</PreferencesSegment>
<PreferencesSegment>
<Subtitle>This device</Subtitle>
<Text>This will delete all local items and preferences.</Text>
<div className="min-h-3" />
<Button
type="danger"
label="Log out and clear local data"
onClick={() => {
appState.accountMenu.setSigningOut(true);
}}
/>
</PreferencesSegment>
</PreferencesGroup>
<OtherSessionsLogoutContainer appState={appState} application={application} />
<ConfirmSignoutContainer
appState={appState}
application={application}
/>
</>
);
});
const ClearSessionDataView: FunctionComponent<{
application: WebApplication;
appState: AppState;
}> = observer(({ application, appState }) => {
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<Title>Clear Session Data</Title>
<div className="min-h-2" />
<Text>This will delete all local items and preferences.</Text>
<div className="min-h-3" />
<Button
type="danger"
label="Clear Session Data"
onClick={() => {
appState.accountMenu.setSigningOut(true);
}}
/>
</PreferencesSegment>
</PreferencesGroup>
<ConfirmSignoutContainer
appState={appState}
application={application}
/>
</>);
});
export const LogOutWrapper: FunctionComponent<{
application: WebApplication;
appState: AppState;
}> = observer(({ application, appState }) => {
const isLoggedIn = application.getUser() != undefined;
if (!isLoggedIn) return <ClearSessionDataView appState={appState} application={application} />;
return <LogOutView appState={appState} application={application} />;
});

View file

@ -1,3 +1,4 @@
export { SubscriptionWrapper } from './subscription/SubscriptionWrapper';
export { Sync } from './Sync';
export { Credentials } from './Credentials';
export { LogOutWrapper } from './LogOutView';

View file

@ -6,6 +6,7 @@ import { SNItem } from '@standardnotes/snjs/dist/@types/models/core/item';
export class AccountMenuState {
show = false;
signingOut = false;
otherSessionsLogOut = false;
server: string | undefined = undefined;
notesAndTags: SNItem[] = [];
isEncryptionEnabled = false;
@ -21,6 +22,7 @@ export class AccountMenuState {
makeObservable(this, {
show: observable,
signingOut: observable,
otherSessionsLogOut: observable,
server: observable,
notesAndTags: observable,
isEncryptionEnabled: observable,
@ -35,6 +37,7 @@ export class AccountMenuState {
setIsEncryptionEnabled: action,
setEncryptionStatusString: action,
setIsBackupEncrypted: action,
setOtherSessionsLogout: action,
notesAndTagsCount: computed
});
@ -106,6 +109,10 @@ export class AccountMenuState {
this.show = !this.show;
};
setOtherSessionsLogout = (otherSessionsLogOut: boolean): void => {
this.otherSessionsLogOut = otherSessionsLogOut;
}
get notesAndTagsCount(): number {
return this.notesAndTags.length;
}

View file

@ -161,11 +161,11 @@ export class AppState {
this.onVisibilityChange = undefined;
}
openSessionsModal() {
openSessionsModal(): void {
this.isSessionsModalVisible = true;
}
closeSessionsModal() {
closeSessionsModal(): void {
this.isSessionsModalVisible = false;
}

View file

@ -166,6 +166,10 @@
margin-bottom: 1rem;
}
.max-w-89 {
max-width: 22.25rem;
}
.w-92 {
width: 23rem;
}
@ -206,6 +210,10 @@
min-height: 0.75rem;
}
.min-h-6 {
min-height: 1.5rem;
}
.border-danger {
border-color: var(--sn-stylekit-danger-color);
}
@ -218,3 +226,12 @@
padding-top: 0.5rem;
}
.px-9 {
padding-left: 2.25rem;
padding-right: 2.25rem;
}
.py-9 {
padding-top: 2.25rem;
padding-bottom: 2.25rem;
}

View file

@ -72,7 +72,7 @@
"@reach/dialog": "^0.13.0",
"@standardnotes/sncrypto-web": "1.5.2",
"@standardnotes/features": "1.6.1",
"@standardnotes/snjs": "2.14.5",
"@standardnotes/snjs": "2.14.6",
"mobx": "^6.3.2",
"mobx-react-lite": "^3.2.0",
"preact": "^10.5.12",

View file

@ -2069,10 +2069,10 @@
"@standardnotes/sncrypto-common" "^1.5.2"
libsodium-wrappers "^0.7.8"
"@standardnotes/snjs@2.14.5":
version "2.14.5"
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.14.5.tgz#1a48b71a4ae5dbdc863cdbbb468e8ebf2113f918"
integrity sha512-PAjv4VgWs//pzG4aYdnQC+4bY8MmwBgkdbwEfCmWRVXv7o/Mfa4t1ds+FbeiyqoEhwCcdWg3E7RRWPqRH93lQQ==
"@standardnotes/snjs@2.14.6":
version "2.14.6"
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.14.6.tgz#fb3f625ec6f22bbee543ccb6fc69e177311c1a4b"
integrity sha512-/PfuyOv2u4Km29yQi2JXYYUteFmHmLYnbQhk96wYHbCwBiNmIyVdSp0VaA4XgVIuZq32QyhhTayjkexq5Huw/Q==
dependencies:
"@standardnotes/auth" "3.7.2"
"@standardnotes/common" "1.1.0"