Merge branch 'develop' into feature/subscription-info-in-preferences
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { MenuItem } from './components';
|
||||
import { PreferencesMenu } from './preferences-menu';
|
||||
import { PreferencesMenu } from './PreferencesMenu';
|
||||
|
||||
export const PreferencesMenuView: FunctionComponent<{
|
||||
menu: PreferencesMenu;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TitleBar, Title } from '@/components/TitleBar';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { AccountPreferences, HelpAndFeedback, Security } from './panes';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { PreferencesMenu } from './preferences-menu';
|
||||
import { PreferencesMenu } from './PreferencesMenu';
|
||||
import { PreferencesMenuView } from './PreferencesMenuView';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { MfaProps } from './panes/two-factor-auth/MfaProps';
|
||||
@@ -24,7 +24,12 @@ const PaneSelector: FunctionComponent<
|
||||
case 'appearance':
|
||||
return null;
|
||||
case 'security':
|
||||
return <Security mfaGateway={props.mfaGateway} />;
|
||||
return (
|
||||
<Security
|
||||
mfaProvider={props.mfaProvider}
|
||||
userProvider={props.userProvider}
|
||||
/>
|
||||
);
|
||||
case 'listed':
|
||||
return null;
|
||||
case 'shortcuts':
|
||||
|
||||
@@ -18,7 +18,8 @@ export const PreferencesViewWrapper: FunctionComponent<PreferencesViewWrapperPro
|
||||
<PreferencesView
|
||||
closePreferences={() => appState.preferences.closePreferences()}
|
||||
application={application}
|
||||
mfaGateway={application}
|
||||
mfaProvider={application}
|
||||
userProvider={application}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,9 @@ export const PreferencesGroup: FunctionComponent = ({ children }) => (
|
||||
<div className="bg-default border-1 border-solid rounded border-gray-300 px-6 py-6 flex flex-col gap-2">
|
||||
{Array.isArray(children)
|
||||
? children
|
||||
.filter((child) => child != undefined && child !== '')
|
||||
.filter(
|
||||
(child) => child != undefined && child !== '' && child !== false
|
||||
)
|
||||
.map((child, i, arr) => (
|
||||
<>
|
||||
{child}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { PreferencesPane } from '../components';
|
||||
import { TwoFactorAuthWrapper } from './two-factor-auth';
|
||||
import { MfaProps } from './two-factor-auth/MfaProps';
|
||||
|
||||
interface SecurityProps extends MfaProps {}
|
||||
|
||||
export const Security: FunctionComponent<SecurityProps> = (props) => (
|
||||
export const Security: FunctionComponent<MfaProps> = (props) => (
|
||||
<PreferencesPane>
|
||||
<TwoFactorAuthWrapper mfaGateway={props.mfaGateway} />
|
||||
<TwoFactorAuthWrapper
|
||||
mfaProvider={props.mfaProvider}
|
||||
userProvider={props.userProvider}
|
||||
/>
|
||||
</PreferencesPane>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
export interface MfaGateway {
|
||||
getUser(): { uuid: string; email: string } | undefined;
|
||||
|
||||
isMfaActivated(): Promise<boolean>;
|
||||
|
||||
generateMfaSecret(): Promise<string>;
|
||||
|
||||
getOtpToken(secret: string): Promise<string>;
|
||||
|
||||
enableMfa(secret: string, otpToken: string): Promise<void>;
|
||||
|
||||
disableMfa(): Promise<void>;
|
||||
}
|
||||
import { MfaProvider, UserProvider } from '../../providers';
|
||||
|
||||
export interface MfaProps {
|
||||
mfaGateway: MfaGateway;
|
||||
userProvider: UserProvider;
|
||||
mfaProvider: MfaProvider;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { action, makeAutoObservable, observable, untracked } from 'mobx';
|
||||
import { MfaGateway } from './MfaProps';
|
||||
import { MfaProvider, UserProvider } from '../../providers';
|
||||
import { action, makeAutoObservable, observable } from 'mobx';
|
||||
|
||||
type ActivationStep = 'scan-qr-code' | 'save-secret-key' | 'verification';
|
||||
type VerificationStatus = 'none' | 'invalid' | 'valid';
|
||||
@@ -15,7 +15,8 @@ export class TwoFactorActivation {
|
||||
private inputOtpToken = '';
|
||||
|
||||
constructor(
|
||||
private mfaGateway: MfaGateway,
|
||||
private mfaProvider: MfaProvider,
|
||||
private userProvider: UserProvider,
|
||||
private readonly _secretKey: string,
|
||||
private _cancelActivation: () => void,
|
||||
private _enabled2FA: () => void
|
||||
@@ -59,7 +60,7 @@ export class TwoFactorActivation {
|
||||
}
|
||||
|
||||
get qrCode(): string {
|
||||
const email = this.mfaGateway.getUser()!.email;
|
||||
const email = this.userProvider.getUser()!.email;
|
||||
return `otpauth://totp/2FA?secret=${this._secretKey}&issuer=Standard%20Notes&label=${email}`;
|
||||
}
|
||||
|
||||
@@ -101,7 +102,7 @@ export class TwoFactorActivation {
|
||||
|
||||
enable2FA(): void {
|
||||
if (this.inputSecretKey === this._secretKey) {
|
||||
this.mfaGateway
|
||||
this.mfaProvider
|
||||
.enableMfa(this.inputSecretKey, this.inputOtpToken)
|
||||
.then(
|
||||
action(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MfaProvider, UserProvider } from '@/preferences/providers';
|
||||
import { action, makeAutoObservable, observable } from 'mobx';
|
||||
import { MfaGateway } from './MfaProps';
|
||||
import { TwoFactorActivation } from './TwoFactorActivation';
|
||||
|
||||
type TwoFactorStatus =
|
||||
@@ -7,20 +7,23 @@ type TwoFactorStatus =
|
||||
| TwoFactorActivation
|
||||
| 'two-factor-disabled';
|
||||
|
||||
export const is2FADisabled = (s: TwoFactorStatus): s is 'two-factor-disabled' =>
|
||||
s === 'two-factor-disabled';
|
||||
export const is2FADisabled = (status: TwoFactorStatus): status is 'two-factor-disabled' =>
|
||||
status === 'two-factor-disabled';
|
||||
|
||||
export const is2FAActivation = (s: TwoFactorStatus): s is TwoFactorActivation =>
|
||||
(s as any).type === 'two-factor-activation';
|
||||
export const is2FAActivation = (status: TwoFactorStatus): status is TwoFactorActivation =>
|
||||
(status as TwoFactorActivation)?.type === 'two-factor-activation';
|
||||
|
||||
export const is2FAEnabled = (s: TwoFactorStatus): s is 'two-factor-enabled' =>
|
||||
s === 'two-factor-enabled';
|
||||
export const is2FAEnabled = (status: TwoFactorStatus): status is 'two-factor-enabled' =>
|
||||
status === 'two-factor-enabled';
|
||||
|
||||
export class TwoFactorAuth {
|
||||
private _status: TwoFactorStatus | 'fetching' = 'fetching';
|
||||
private _errorMessage: string | null;
|
||||
|
||||
constructor(private readonly mfaGateway: MfaGateway) {
|
||||
constructor(
|
||||
private readonly mfaProvider: MfaProvider,
|
||||
private readonly userProvider: UserProvider
|
||||
) {
|
||||
this._errorMessage = null;
|
||||
|
||||
makeAutoObservable<
|
||||
@@ -37,12 +40,13 @@ export class TwoFactorAuth {
|
||||
private startActivation(): void {
|
||||
const setDisabled = action(() => (this._status = 'two-factor-disabled'));
|
||||
const setEnabled = action(() => (this._status = 'two-factor-enabled'));
|
||||
this.mfaGateway
|
||||
this.mfaProvider
|
||||
.generateMfaSecret()
|
||||
.then(
|
||||
action((secret) => {
|
||||
this._status = new TwoFactorActivation(
|
||||
this.mfaGateway,
|
||||
this.mfaProvider,
|
||||
this.userProvider,
|
||||
secret,
|
||||
setDisabled,
|
||||
setEnabled
|
||||
@@ -57,7 +61,7 @@ export class TwoFactorAuth {
|
||||
}
|
||||
|
||||
private deactivate2FA(): void {
|
||||
this.mfaGateway
|
||||
this.mfaProvider
|
||||
.disableMfa()
|
||||
.then(
|
||||
action(() => {
|
||||
@@ -72,18 +76,21 @@ export class TwoFactorAuth {
|
||||
}
|
||||
|
||||
private get isLoggedIn(): boolean {
|
||||
return this.mfaGateway.getUser() != undefined;
|
||||
return this.userProvider.getUser() != undefined;
|
||||
}
|
||||
|
||||
fetchStatus(): void {
|
||||
this._status = 'fetching';
|
||||
|
||||
if (!this.isLoggedIn) {
|
||||
this.setError('To enable 2FA, sign in or register for an account.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.mfaGateway
|
||||
if (!this.isMfaFeatureAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.mfaProvider
|
||||
.isMfaActivated()
|
||||
.then(
|
||||
action((active) => {
|
||||
@@ -99,7 +106,7 @@ export class TwoFactorAuth {
|
||||
);
|
||||
}
|
||||
|
||||
setError(errorMessage: string | null): void {
|
||||
private setError(errorMessage: string | null): void {
|
||||
this._errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
@@ -108,6 +115,10 @@ export class TwoFactorAuth {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isMfaFeatureAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._status === 'two-factor-disabled') {
|
||||
return this.startActivation();
|
||||
}
|
||||
@@ -118,6 +129,12 @@ export class TwoFactorAuth {
|
||||
}
|
||||
|
||||
get errorMessage(): string | null {
|
||||
if (!this.isLoggedIn) {
|
||||
return 'Two-factor authentication not available / Sign in or register for an account to configure 2FA';
|
||||
}
|
||||
if (!this.isMfaFeatureAvailable) {
|
||||
return 'Two-factor authentication not available / A paid subscription plan is required to enable 2FA.';
|
||||
}
|
||||
return this._errorMessage;
|
||||
}
|
||||
|
||||
@@ -127,4 +144,8 @@ export class TwoFactorAuth {
|
||||
}
|
||||
return this._status;
|
||||
}
|
||||
|
||||
private get isMfaFeatureAvailable(): boolean {
|
||||
return this.mfaProvider.isMfaFeatureAvailable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,45 +7,38 @@ import {
|
||||
} from '../../components';
|
||||
import { Switch } from '../../../components/Switch';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import {
|
||||
is2FAActivation,
|
||||
is2FADisabled,
|
||||
is2FAEnabled,
|
||||
TwoFactorAuth,
|
||||
} from './TwoFactorAuth';
|
||||
import { TwoFactorDisabledView } from './TwoFactorDisabledView';
|
||||
import { is2FAActivation, is2FADisabled, TwoFactorAuth } from './TwoFactorAuth';
|
||||
import { TwoFactorActivationView } from './TwoFactorActivationView';
|
||||
|
||||
export const TwoFactorAuthView: FunctionComponent<{
|
||||
auth: TwoFactorAuth;
|
||||
}> = observer(({ auth }) => (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex-grow flex flex-col">
|
||||
<Title>Two-factor authentication</Title>
|
||||
<Text>
|
||||
An extra layer of security when logging in to your account.
|
||||
</Text>
|
||||
{auth.errorMessage != null && (
|
||||
<Text className="color-danger">{auth.errorMessage}</Text>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={!is2FADisabled(auth.status)}
|
||||
onChange={() => auth.toggle2FA()}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
|
||||
{is2FAActivation(auth.status) ? (
|
||||
<TwoFactorActivationView activation={auth.status} />
|
||||
) : null}
|
||||
|
||||
{!is2FAEnabled(auth.status) ? (
|
||||
}> = observer(({ auth }) => {
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<TwoFactorDisabledView />
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex-grow flex flex-col">
|
||||
<Title>Two-factor authentication</Title>
|
||||
<Text>
|
||||
An extra layer of security when logging in to your account.
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!is2FADisabled(auth.status)}
|
||||
onChange={auth.toggle2FA}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
) : null}
|
||||
</PreferencesGroup>
|
||||
));
|
||||
|
||||
{is2FAActivation(auth.status) && (
|
||||
<TwoFactorActivationView activation={auth.status} />
|
||||
)}
|
||||
|
||||
{auth.errorMessage != null && (
|
||||
<PreferencesSegment>
|
||||
<Text className="color-danger">{auth.errorMessage}</Text>
|
||||
</PreferencesSegment>
|
||||
)}
|
||||
</PreferencesGroup>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Text } from '../../components';
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
export const TwoFactorDisabledView: FunctionComponent = () => (
|
||||
<Text>
|
||||
Enabling two-factor authentication will sign you out of all other sessions.{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</Text>
|
||||
);
|
||||
@@ -4,10 +4,10 @@ import { MfaProps } from './MfaProps';
|
||||
import { TwoFactorAuth } from './TwoFactorAuth';
|
||||
import { TwoFactorAuthView } from './TwoFactorAuthView';
|
||||
|
||||
export const TwoFactorAuthWrapper: FunctionComponent<MfaProps> = ({
|
||||
mfaGateway,
|
||||
}) => {
|
||||
const [auth] = useState(() => new TwoFactorAuth(mfaGateway));
|
||||
export const TwoFactorAuthWrapper: FunctionComponent<MfaProps> = (props) => {
|
||||
const [auth] = useState(
|
||||
() => new TwoFactorAuth(props.mfaProvider, props.userProvider)
|
||||
);
|
||||
auth.fetchStatus();
|
||||
return <TwoFactorAuthView auth={auth} />;
|
||||
};
|
||||
|
||||
13
app/assets/javascripts/preferences/providers/MfaProvider.ts
Normal file
13
app/assets/javascripts/preferences/providers/MfaProvider.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface MfaProvider {
|
||||
isMfaActivated(): Promise<boolean>;
|
||||
|
||||
generateMfaSecret(): Promise<string>;
|
||||
|
||||
getOtpToken(secret: string): Promise<string>;
|
||||
|
||||
enableMfa(secret: string, otpToken: string): Promise<void>;
|
||||
|
||||
disableMfa(): Promise<void>;
|
||||
|
||||
isMfaFeatureAvailable(): boolean;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface UserProvider {
|
||||
getUser(): { uuid: string; email: string } | undefined;
|
||||
}
|
||||
2
app/assets/javascripts/preferences/providers/index.ts
Normal file
2
app/assets/javascripts/preferences/providers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './MfaProvider';
|
||||
export * from './UserProvider';
|
||||
@@ -71,7 +71,8 @@
|
||||
"@reach/checkbox": "^0.13.2",
|
||||
"@reach/dialog": "^0.13.0",
|
||||
"@standardnotes/sncrypto-web": "1.5.2",
|
||||
"@standardnotes/snjs": "2.12.1",
|
||||
"@standardnotes/features": "1.6.1",
|
||||
"@standardnotes/snjs": "2.12.3",
|
||||
"mobx": "^6.3.2",
|
||||
"mobx-react-lite": "^3.2.0",
|
||||
"preact": "^10.5.12",
|
||||
|
||||
@@ -2042,6 +2042,13 @@
|
||||
dependencies:
|
||||
"@standardnotes/common" "^1.1.0"
|
||||
|
||||
"@standardnotes/features@1.6.1":
|
||||
version "1.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.6.1.tgz#bfa227bd231dc1b54449936663731f5132b08e23"
|
||||
integrity sha512-IC6fEotUqs23JdZx96JnEgARxwYzjmPz3UwU/uVn8hHjxPev/W0nyZFRiSlj4v+dod0jSa6FTR8iLLsOQ6M4Ug==
|
||||
dependencies:
|
||||
"@standardnotes/common" "^1.1.0"
|
||||
|
||||
"@standardnotes/settings@1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@standardnotes/settings/-/settings-1.2.0.tgz#d7936c788138265b0720085ca9e63358d3092459"
|
||||
|
||||
Reference in New Issue
Block a user