feat: new lock screen and challenge modal design (#957)
This commit is contained in:
@@ -17,7 +17,7 @@ import { NoteGroupView } from '@/components/NoteGroupView';
|
|||||||
import { Footer } from '@/components/Footer';
|
import { Footer } from '@/components/Footer';
|
||||||
import { SessionsModal } from '@/components/SessionsModal';
|
import { SessionsModal } from '@/components/SessionsModal';
|
||||||
import { PreferencesViewWrapper } from '@/components/Preferences/PreferencesViewWrapper';
|
import { PreferencesViewWrapper } from '@/components/Preferences/PreferencesViewWrapper';
|
||||||
import { ChallengeModal } from '@/components/ChallengeModal';
|
import { ChallengeModal } from '@/components/ChallengeModal/ChallengeModal';
|
||||||
import { NotesContextMenu } from '@/components/NotesContextMenu';
|
import { NotesContextMenu } from '@/components/NotesContextMenu';
|
||||||
import { PurchaseFlowWrapper } from '@/components/PurchaseFlow/PurchaseFlowWrapper';
|
import { PurchaseFlowWrapper } from '@/components/PurchaseFlow/PurchaseFlowWrapper';
|
||||||
import { render } from 'preact';
|
import { render } from 'preact';
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ const getClassName = (
|
|||||||
|
|
||||||
let focusHoverStates =
|
let focusHoverStates =
|
||||||
variant === 'normal'
|
variant === 'normal'
|
||||||
? 'focus:bg-contrast hover:bg-contrast'
|
? 'focus:bg-contrast focus:outline-none hover:bg-contrast'
|
||||||
: 'hover:brightness-130 focus:brightness-130';
|
: 'hover:brightness-130 focus:outline-none focus:brightness-130';
|
||||||
|
|
||||||
if (danger) {
|
if (danger) {
|
||||||
colors =
|
colors =
|
||||||
@@ -39,8 +39,8 @@ const getClassName = (
|
|||||||
: 'bg-grey-2 color-info-contrast';
|
: 'bg-grey-2 color-info-contrast';
|
||||||
focusHoverStates =
|
focusHoverStates =
|
||||||
variant === 'normal'
|
variant === 'normal'
|
||||||
? 'focus:bg-default hover:bg-default'
|
? 'focus:bg-default focus:outline-none hover:bg-default'
|
||||||
: 'focus:brightness-default hover:brightness-default';
|
: 'focus:brightness-default focus:outline-none hover:brightness-default';
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`;
|
return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`;
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
import { WebApplication } from '@/ui_models/application';
|
|
||||||
import { Dialog } from '@reach/dialog';
|
|
||||||
import {
|
|
||||||
ChallengeValue,
|
|
||||||
removeFromArray,
|
|
||||||
Challenge,
|
|
||||||
ChallengeReason,
|
|
||||||
ChallengePrompt,
|
|
||||||
ChallengeValidation,
|
|
||||||
ProtectionSessionDurations,
|
|
||||||
} from '@standardnotes/snjs';
|
|
||||||
import { confirmDialog } from '@/services/alertService';
|
|
||||||
import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings';
|
|
||||||
import { createRef } from 'preact';
|
|
||||||
import { PureComponent } from '@/components/Abstract/PureComponent';
|
|
||||||
|
|
||||||
type InputValue = {
|
|
||||||
prompt: ChallengePrompt;
|
|
||||||
value: string | number | boolean;
|
|
||||||
invalid: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Values = Record<number, InputValue>;
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
prompts: ChallengePrompt[];
|
|
||||||
values: Partial<Values>;
|
|
||||||
processing: boolean;
|
|
||||||
forgotPasscode: boolean;
|
|
||||||
showForgotPasscodeLink: boolean;
|
|
||||||
processingPrompts: ChallengePrompt[];
|
|
||||||
hasAccount: boolean;
|
|
||||||
protectedNoteAccessDuration: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
challenge: Challenge;
|
|
||||||
application: WebApplication;
|
|
||||||
onDismiss: (challenge: Challenge) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ChallengeModal extends PureComponent<Props, State> {
|
|
||||||
submitting = false;
|
|
||||||
protectionsSessionDurations = ProtectionSessionDurations;
|
|
||||||
protectionsSessionValidation = ChallengeValidation.ProtectionSessionDuration;
|
|
||||||
private initialFocusRef = createRef<HTMLInputElement>();
|
|
||||||
|
|
||||||
constructor(props: Props) {
|
|
||||||
super(props, props.application);
|
|
||||||
|
|
||||||
const values = {} as Values;
|
|
||||||
const prompts = this.props.challenge.prompts;
|
|
||||||
for (const prompt of prompts) {
|
|
||||||
values[prompt.id] = {
|
|
||||||
prompt,
|
|
||||||
value: prompt.initialValue ?? '',
|
|
||||||
invalid: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const showForgotPasscodeLink = [
|
|
||||||
ChallengeReason.ApplicationUnlock,
|
|
||||||
ChallengeReason.Migration,
|
|
||||||
].includes(this.props.challenge.reason);
|
|
||||||
this.state = {
|
|
||||||
prompts,
|
|
||||||
values,
|
|
||||||
processing: false,
|
|
||||||
forgotPasscode: false,
|
|
||||||
showForgotPasscodeLink,
|
|
||||||
hasAccount: this.application.hasAccount(),
|
|
||||||
processingPrompts: [],
|
|
||||||
protectedNoteAccessDuration: ProtectionSessionDurations[0].valueInSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount(): void {
|
|
||||||
super.componentDidMount();
|
|
||||||
|
|
||||||
this.application.addChallengeObserver(this.props.challenge, {
|
|
||||||
onValidValue: (value) => {
|
|
||||||
this.state.values[value.prompt.id]!.invalid = false;
|
|
||||||
removeFromArray(this.state.processingPrompts, value.prompt);
|
|
||||||
this.reloadProcessingStatus();
|
|
||||||
this.afterStateChange();
|
|
||||||
},
|
|
||||||
onInvalidValue: (value) => {
|
|
||||||
this.state.values[value.prompt.id]!.invalid = true;
|
|
||||||
/** If custom validation, treat all values together and not individually */
|
|
||||||
if (!value.prompt.validates) {
|
|
||||||
this.setState({ processingPrompts: [], processing: false });
|
|
||||||
} else {
|
|
||||||
removeFromArray(this.state.processingPrompts, value.prompt);
|
|
||||||
this.reloadProcessingStatus();
|
|
||||||
}
|
|
||||||
this.afterStateChange();
|
|
||||||
},
|
|
||||||
onComplete: () => {
|
|
||||||
this.dismiss();
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
this.dismiss();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit() {
|
|
||||||
(this.application as unknown) = undefined;
|
|
||||||
(this.props.challenge as unknown) = undefined;
|
|
||||||
super.deinit();
|
|
||||||
}
|
|
||||||
|
|
||||||
reloadProcessingStatus() {
|
|
||||||
return this.setState({
|
|
||||||
processing: this.state.processingPrompts.length > 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
destroyLocalData = async () => {
|
|
||||||
if (
|
|
||||||
await confirmDialog({
|
|
||||||
text: STRING_SIGN_OUT_CONFIRMATION,
|
|
||||||
confirmButtonStyle: 'danger',
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
this.dismiss();
|
|
||||||
this.application.user.signOut();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
cancel = () => {
|
|
||||||
if (this.props.challenge.cancelable) {
|
|
||||||
this.application.cancelChallenge(this.props.challenge);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onForgotPasscodeClick = () => {
|
|
||||||
this.setState({
|
|
||||||
forgotPasscode: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onTextValueChange = (prompt: ChallengePrompt) => {
|
|
||||||
const values = this.state.values;
|
|
||||||
values[prompt.id]!.invalid = false;
|
|
||||||
this.setState({ values });
|
|
||||||
};
|
|
||||||
|
|
||||||
onNumberValueChange(prompt: ChallengePrompt, value: number) {
|
|
||||||
const values = this.state.values;
|
|
||||||
values[prompt.id]!.invalid = false;
|
|
||||||
values[prompt.id]!.value = value;
|
|
||||||
this.setState({ values });
|
|
||||||
}
|
|
||||||
|
|
||||||
validate() {
|
|
||||||
let failed = 0;
|
|
||||||
for (const prompt of this.state.prompts) {
|
|
||||||
const value = this.state.values[prompt.id]!;
|
|
||||||
if (typeof value.value === 'string' && value.value.length === 0) {
|
|
||||||
this.state.values[prompt.id]!.invalid = true;
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return failed === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
submit = async () => {
|
|
||||||
if (!this.validate()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.submitting || this.state.processing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.submitting = true;
|
|
||||||
await this.setState({ processing: true });
|
|
||||||
const values: ChallengeValue[] = [];
|
|
||||||
for (const inputValue of Object.values(this.state.values)) {
|
|
||||||
const rawValue = inputValue!.value;
|
|
||||||
const value = new ChallengeValue(inputValue!.prompt, rawValue);
|
|
||||||
values.push(value);
|
|
||||||
}
|
|
||||||
const processingPrompts = values.map((v) => v.prompt);
|
|
||||||
await this.setState({
|
|
||||||
processingPrompts: processingPrompts,
|
|
||||||
processing: processingPrompts.length > 0,
|
|
||||||
});
|
|
||||||
/**
|
|
||||||
* Unfortunately neccessary to wait 50ms so that the above setState call completely
|
|
||||||
* updates the UI to change processing state, before we enter into UI blocking operation
|
|
||||||
* (crypto key generation)
|
|
||||||
*/
|
|
||||||
setTimeout(() => {
|
|
||||||
if (values.length > 0) {
|
|
||||||
this.application.submitValuesForChallenge(this.props.challenge, values);
|
|
||||||
} else {
|
|
||||||
this.setState({ processing: false });
|
|
||||||
}
|
|
||||||
this.submitting = false;
|
|
||||||
}, 50);
|
|
||||||
};
|
|
||||||
|
|
||||||
afterStateChange() {
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
dismiss = () => {
|
|
||||||
this.props.onDismiss(this.props.challenge);
|
|
||||||
};
|
|
||||||
|
|
||||||
private renderChallengePrompts() {
|
|
||||||
return this.state.prompts.map((prompt, index) => (
|
|
||||||
<>
|
|
||||||
{/** ProtectionSessionDuration can't just be an input field */}
|
|
||||||
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
|
|
||||||
<div key={prompt.id} className="sk-panel-row">
|
|
||||||
<div className="sk-horizontal-group mt-3">
|
|
||||||
<div className="sk-p sk-bold">Allow protected access for</div>
|
|
||||||
{ProtectionSessionDurations.map((option) => (
|
|
||||||
<a
|
|
||||||
className={
|
|
||||||
'sk-a info ' +
|
|
||||||
(option.valueInSeconds ===
|
|
||||||
this.state.values[prompt.id]!.value
|
|
||||||
? 'boxed'
|
|
||||||
: '')
|
|
||||||
}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
this.onNumberValueChange(prompt, option.valueInSeconds);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div key={prompt.id} className="sk-panel-row">
|
|
||||||
<form
|
|
||||||
className="w-full"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
this.submit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="sk-input contrast"
|
|
||||||
value={this.state.values[prompt.id]!.value as string | number}
|
|
||||||
onChange={(event) => {
|
|
||||||
const value = (event.target as HTMLInputElement).value;
|
|
||||||
this.state.values[prompt.id]!.value = value;
|
|
||||||
this.onTextValueChange(prompt);
|
|
||||||
}}
|
|
||||||
ref={index === 0 ? this.initialFocusRef : undefined}
|
|
||||||
placeholder={prompt.title}
|
|
||||||
type={prompt.secureTextEntry ? 'password' : 'text'}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{this.state.values[prompt.id]!.invalid && (
|
|
||||||
<div className="sk-panel-row centered">
|
|
||||||
<label className="sk-label danger">
|
|
||||||
Invalid authentication. Please try again.
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (!this.state.prompts) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
initialFocusRef={this.initialFocusRef}
|
|
||||||
onDismiss={() => {
|
|
||||||
if (this.props.challenge.cancelable) {
|
|
||||||
this.cancel();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="challenge-modal sk-modal-content">
|
|
||||||
<div className="sn-component">
|
|
||||||
<div className="sk-panel">
|
|
||||||
<div className="sk-panel-header">
|
|
||||||
<div className="sk-panel-header-title">
|
|
||||||
{this.props.challenge.modalTitle}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sk-panel-content">
|
|
||||||
<div className="sk-panel-section">
|
|
||||||
<div className="sk-p sk-panel-row centered prompt">
|
|
||||||
<strong>{this.props.challenge.heading}</strong>
|
|
||||||
</div>
|
|
||||||
{this.props.challenge.subheading && (
|
|
||||||
<div className="sk-p sk-panel-row centered subprompt">
|
|
||||||
{this.props.challenge.subheading}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sk-panel-section">
|
|
||||||
{this.renderChallengePrompts()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sk-panel-footer extra-padding">
|
|
||||||
<button
|
|
||||||
className={
|
|
||||||
'sn-button w-full ' +
|
|
||||||
(this.state.processing ? 'neutral' : 'info')
|
|
||||||
}
|
|
||||||
disabled={this.state.processing}
|
|
||||||
onClick={() => this.submit()}
|
|
||||||
>
|
|
||||||
{this.state.processing ? 'Generating Keys…' : 'Submit'}
|
|
||||||
</button>
|
|
||||||
{this.props.challenge.cancelable && (
|
|
||||||
<>
|
|
||||||
<div className="sk-panel-row"></div>
|
|
||||||
<a
|
|
||||||
className="sk-panel-row sk-a info centered text-sm"
|
|
||||||
onClick={() => this.cancel()}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{this.state.showForgotPasscodeLink && (
|
|
||||||
<div className="sk-panel-footer">
|
|
||||||
{this.state.forgotPasscode ? (
|
|
||||||
<>
|
|
||||||
<p className="sk-panel-row sk-p text-center">
|
|
||||||
{this.state.hasAccount
|
|
||||||
? 'If you forgot your application passcode, your ' +
|
|
||||||
'only option is to clear your local data from this ' +
|
|
||||||
'device and sign back in to your account.'
|
|
||||||
: 'If you forgot your application passcode, your ' +
|
|
||||||
'only option is to delete your data.'}
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
className="sk-panel-row sk-a danger centered"
|
|
||||||
onClick={() => {
|
|
||||||
this.destroyLocalData();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete Local Data
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
className="sk-panel-row sk-a info centered"
|
|
||||||
onClick={() => this.onForgotPasscodeClick()}
|
|
||||||
>
|
|
||||||
Forgot your passcode?
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<div className="sk-panel-row"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { WebApplication } from '@/ui_models/application';
|
||||||
|
import { DialogContent, DialogOverlay } from '@reach/dialog';
|
||||||
|
import {
|
||||||
|
ButtonType,
|
||||||
|
Challenge,
|
||||||
|
ChallengePrompt,
|
||||||
|
ChallengeReason,
|
||||||
|
ChallengeValue,
|
||||||
|
removeFromArray,
|
||||||
|
} from '@standardnotes/snjs';
|
||||||
|
import { ProtectedIllustration } from '@standardnotes/stylekit';
|
||||||
|
import { FunctionComponent } from 'preact';
|
||||||
|
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import { Icon } from '../Icon';
|
||||||
|
import { ChallengeModalPrompt } from './ChallengePrompt';
|
||||||
|
|
||||||
|
type InputValue = {
|
||||||
|
prompt: ChallengePrompt;
|
||||||
|
value: string | number | boolean;
|
||||||
|
invalid: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
application: WebApplication;
|
||||||
|
challenge: Challenge;
|
||||||
|
onDismiss: (challenge: Challenge) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateValues = (
|
||||||
|
values: ChallengeModalValues,
|
||||||
|
prompts: ChallengePrompt[]
|
||||||
|
): ChallengeModalValues | undefined => {
|
||||||
|
let hasInvalidValues = false;
|
||||||
|
const validatedValues = { ...values };
|
||||||
|
for (const prompt of prompts) {
|
||||||
|
const value = validatedValues[prompt.id];
|
||||||
|
if (typeof value.value === 'string' && value.value.length === 0) {
|
||||||
|
validatedValues[prompt.id].invalid = true;
|
||||||
|
hasInvalidValues = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasInvalidValues) {
|
||||||
|
return validatedValues;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChallengeModal: FunctionComponent<Props> = ({
|
||||||
|
application,
|
||||||
|
challenge,
|
||||||
|
onDismiss,
|
||||||
|
}) => {
|
||||||
|
const [values, setValues] = useState<ChallengeModalValues>(() => {
|
||||||
|
const values = {} as ChallengeModalValues;
|
||||||
|
for (const prompt of challenge.prompts) {
|
||||||
|
values[prompt.id] = {
|
||||||
|
prompt,
|
||||||
|
value: prompt.initialValue ?? '',
|
||||||
|
invalid: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [, setProcessingPrompts] = useState<ChallengePrompt[]>([]);
|
||||||
|
const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false);
|
||||||
|
const shouldShowForgotPasscode = [
|
||||||
|
ChallengeReason.ApplicationUnlock,
|
||||||
|
ChallengeReason.Migration,
|
||||||
|
].includes(challenge.reason);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const validatedValues = validateValues(values, challenge.prompts);
|
||||||
|
if (!validatedValues) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSubmitting || isProcessing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setIsProcessing(true);
|
||||||
|
const valuesToProcess: ChallengeValue[] = [];
|
||||||
|
for (const inputValue of Object.values(validatedValues)) {
|
||||||
|
const rawValue = inputValue.value;
|
||||||
|
const value = new ChallengeValue(inputValue.prompt, rawValue);
|
||||||
|
valuesToProcess.push(value);
|
||||||
|
}
|
||||||
|
const processingPrompts = valuesToProcess.map((v) => v.prompt);
|
||||||
|
setIsProcessing(processingPrompts.length > 0);
|
||||||
|
setProcessingPrompts(processingPrompts);
|
||||||
|
/**
|
||||||
|
* Unfortunately neccessary to wait 50ms so that the above setState call completely
|
||||||
|
* updates the UI to change processing state, before we enter into UI blocking operation
|
||||||
|
* (crypto key generation)
|
||||||
|
*/
|
||||||
|
setTimeout(() => {
|
||||||
|
if (valuesToProcess.length > 0) {
|
||||||
|
application.submitValuesForChallenge(challenge, valuesToProcess);
|
||||||
|
} else {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onValueChange = useCallback(
|
||||||
|
(value: string | number, prompt: ChallengePrompt) => {
|
||||||
|
const newValues = { ...values };
|
||||||
|
newValues[prompt.id].invalid = false;
|
||||||
|
newValues[prompt.id].value = value;
|
||||||
|
setValues(newValues);
|
||||||
|
},
|
||||||
|
[values]
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
if (challenge.cancelable) {
|
||||||
|
onDismiss(challenge);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const removeChallengeObserver = application.addChallengeObserver(
|
||||||
|
challenge,
|
||||||
|
{
|
||||||
|
onValidValue: (value) => {
|
||||||
|
setValues((values) => {
|
||||||
|
const newValues = { ...values };
|
||||||
|
newValues[value.prompt.id].invalid = false;
|
||||||
|
return newValues;
|
||||||
|
});
|
||||||
|
setProcessingPrompts((currentlyProcessingPrompts) => {
|
||||||
|
const processingPrompts = currentlyProcessingPrompts.slice();
|
||||||
|
removeFromArray(processingPrompts, value.prompt);
|
||||||
|
setIsProcessing(processingPrompts.length > 0);
|
||||||
|
return processingPrompts;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onInvalidValue: (value) => {
|
||||||
|
setValues((values) => {
|
||||||
|
const newValues = { ...values };
|
||||||
|
newValues[value.prompt.id].invalid = true;
|
||||||
|
return newValues;
|
||||||
|
});
|
||||||
|
/** If custom validation, treat all values together and not individually */
|
||||||
|
if (!value.prompt.validates) {
|
||||||
|
setProcessingPrompts([]);
|
||||||
|
setIsProcessing(false);
|
||||||
|
} else {
|
||||||
|
setProcessingPrompts((currentlyProcessingPrompts) => {
|
||||||
|
const processingPrompts = currentlyProcessingPrompts.slice();
|
||||||
|
removeFromArray(processingPrompts, value.prompt);
|
||||||
|
setIsProcessing(processingPrompts.length > 0);
|
||||||
|
return processingPrompts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
onDismiss(challenge);
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
onDismiss(challenge);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeChallengeObserver();
|
||||||
|
};
|
||||||
|
}, [application, challenge, onDismiss]);
|
||||||
|
|
||||||
|
if (!challenge.prompts) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogOverlay
|
||||||
|
className={`sn-component ${
|
||||||
|
challenge.reason === ChallengeReason.ApplicationUnlock
|
||||||
|
? 'challenge-modal-overlay'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
onDismiss={closeModal}
|
||||||
|
dangerouslyBypassFocusLock={bypassModalFocusLock}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className={`challenge-modal flex flex-col items-center bg-default p-8 rounded relative ${
|
||||||
|
challenge.reason !== ChallengeReason.ApplicationUnlock
|
||||||
|
? 'shadow-overlay-light border-1 border-solid border-main'
|
||||||
|
: 'focus:shadow-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{challenge.cancelable && (
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
aria-label="Close modal"
|
||||||
|
className="flex p-1 bg-transparent border-0 cursor-pointer absolute top-4 right-4"
|
||||||
|
>
|
||||||
|
<Icon type="close" className="color-neutral" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<ProtectedIllustration className="w-30 h-30 mb-4" />
|
||||||
|
<div className="font-bold text-lg text-center max-w-76 mb-3">
|
||||||
|
{challenge.heading}
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-sm max-w-76 mb-4">
|
||||||
|
{challenge.subheading}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="flex flex-col items-center min-w-76 mb-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{challenge.prompts.map((prompt, index) => (
|
||||||
|
<ChallengeModalPrompt
|
||||||
|
key={prompt.id}
|
||||||
|
prompt={prompt}
|
||||||
|
values={values}
|
||||||
|
index={index}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
isInvalid={values[prompt.id].invalid}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</form>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="min-w-76 mb-3.5"
|
||||||
|
onClick={() => {
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Generating Keys...' : 'Unlock'}
|
||||||
|
</Button>
|
||||||
|
{shouldShowForgotPasscode && (
|
||||||
|
<Button
|
||||||
|
className="flex items-center justify-center min-w-76"
|
||||||
|
onClick={() => {
|
||||||
|
setBypassModalFocusLock(true);
|
||||||
|
application.alertService
|
||||||
|
.confirm(
|
||||||
|
'If you forgot your local passcode, your only option is to clear your local data from this device and sign back in to your account.',
|
||||||
|
'Forgot passcode?',
|
||||||
|
'Delete local data',
|
||||||
|
ButtonType.Danger
|
||||||
|
)
|
||||||
|
.then((shouldDeleteLocalData) => {
|
||||||
|
if (shouldDeleteLocalData) {
|
||||||
|
application.user.signOut();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => {
|
||||||
|
setBypassModalFocusLock(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="help" className="mr-2 color-neutral" />
|
||||||
|
Forgot passcode?
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</DialogOverlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
ChallengePrompt,
|
||||||
|
ChallengeValidation,
|
||||||
|
ProtectionSessionDurations,
|
||||||
|
} from '@standardnotes/snjs';
|
||||||
|
import { FunctionComponent } from 'preact';
|
||||||
|
import { useEffect, useRef } from 'preact/hooks';
|
||||||
|
import { DecoratedInput } from '../DecoratedInput';
|
||||||
|
import { DecoratedPasswordInput } from '../DecoratedPasswordInput';
|
||||||
|
import { ChallengeModalValues } from './ChallengeModal';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
prompt: ChallengePrompt;
|
||||||
|
values: ChallengeModalValues;
|
||||||
|
index: number;
|
||||||
|
onValueChange: (value: string | number, prompt: ChallengePrompt) => void;
|
||||||
|
isInvalid: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChallengeModalPrompt: FunctionComponent<Props> = ({
|
||||||
|
prompt,
|
||||||
|
values,
|
||||||
|
index,
|
||||||
|
onValueChange,
|
||||||
|
isInvalid,
|
||||||
|
}) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (index === 0) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [index]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInvalid) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [isInvalid]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
|
||||||
|
<div className="mt-3 min-w-76">
|
||||||
|
<div className="text-sm font-medium mb-2">
|
||||||
|
Allow protected access for
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between bg-grey-4 rounded p-1">
|
||||||
|
{ProtectionSessionDurations.map((option) => {
|
||||||
|
const selected =
|
||||||
|
option.valueInSeconds === values[prompt.id].value;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={`cursor-pointer px-2 py-1.5 rounded ${
|
||||||
|
selected
|
||||||
|
? 'bg-default color-foreground font-semibold'
|
||||||
|
: 'color-grey-0 hover:bg-grey-3'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`session-duration-${prompt.id}`}
|
||||||
|
className={
|
||||||
|
'appearance-none m-0 focus:shadow-none focus:outline-none'
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
marginRight: 0,
|
||||||
|
}}
|
||||||
|
checked={selected}
|
||||||
|
onChange={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onValueChange(option.valueInSeconds, prompt);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : prompt.secureTextEntry ? (
|
||||||
|
<DecoratedPasswordInput
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder={prompt.placeholder}
|
||||||
|
className={`w-full max-w-76 ${isInvalid ? 'border-danger' : ''}`}
|
||||||
|
onChange={(value) => onValueChange(value, prompt)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DecoratedInput
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder={prompt.placeholder}
|
||||||
|
className={`w-full max-w-76 ${isInvalid ? 'border-danger' : ''}`}
|
||||||
|
onChange={(value) => onValueChange(value, prompt)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isInvalid && (
|
||||||
|
<div className="text-sm color-danger mt-2">
|
||||||
|
Invalid authentication, please try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -23,7 +23,7 @@ const getClassNames = (
|
|||||||
container: `flex items-stretch position-relative bg-default border-1 border-solid border-main rounded focus-within:ring-info overflow-hidden ${
|
container: `flex items-stretch position-relative bg-default border-1 border-solid border-main rounded focus-within:ring-info overflow-hidden ${
|
||||||
!hasLeftDecorations && !hasRightDecorations ? 'px-2 py-1.5' : ''
|
!hasLeftDecorations && !hasRightDecorations ? 'px-2 py-1.5' : ''
|
||||||
}`,
|
}`,
|
||||||
input: `w-full border-0 focus:shadow-none ${
|
input: `w-full border-0 focus:shadow-none bg-transparent ${
|
||||||
!hasLeftDecorations && hasRightDecorations ? 'pl-2' : ''
|
!hasLeftDecorations && hasRightDecorations ? 'pl-2' : ''
|
||||||
} ${hasRightDecorations ? 'pr-2' : ''}`,
|
} ${hasRightDecorations ? 'pr-2' : ''}`,
|
||||||
disabled: 'bg-grey-5 cursor-not-allowed',
|
disabled: 'bg-grey-5 cursor-not-allowed',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Toggle: FunctionComponent<{
|
|||||||
setIsToggled: StateUpdater<boolean>;
|
setIsToggled: StateUpdater<boolean>;
|
||||||
}> = ({ isToggled, setIsToggled }) => (
|
}> = ({ isToggled, setIsToggled }) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className="w-5 h-5 justify-center sk-circle hover:bg-grey-4"
|
className="w-5 h-5 justify-center sk-circle hover:bg-grey-4 color-neutral"
|
||||||
icon={isToggled ? 'eye-off' : 'eye'}
|
icon={isToggled ? 'eye-off' : 'eye'}
|
||||||
iconClassName="sn-icon--small"
|
iconClassName="sn-icon--small"
|
||||||
title="Show/hide password"
|
title="Show/hide password"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const IconButton: FunctionComponent<Props> = ({
|
|||||||
const focusableClass = focusable ? '' : 'focus:shadow-none';
|
const focusableClass = focusable ? '' : 'focus:shadow-none';
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
title={title}
|
title={title}
|
||||||
className={`no-border cursor-pointer bg-transparent flex flex-row items-center hover:brightness-130 p-0 ${focusableClass} ${className}`}
|
className={`no-border cursor-pointer bg-transparent flex flex-row items-center hover:brightness-130 p-0 ${focusableClass} ${className}`}
|
||||||
onClick={click}
|
onClick={click}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
VNode,
|
VNode,
|
||||||
RefCallback,
|
RefCallback,
|
||||||
ComponentChild,
|
ComponentChild,
|
||||||
|
toChildArray,
|
||||||
} from 'preact';
|
} from 'preact';
|
||||||
import { useEffect, useRef } from 'preact/hooks';
|
import { useEffect, useRef } from 'preact/hooks';
|
||||||
import { JSXInternal } from 'preact/src/jsx';
|
import { JSXInternal } from 'preact/src/jsx';
|
||||||
@@ -117,7 +118,7 @@ export const Menu: FunctionComponent<MenuProps> = ({
|
|||||||
style={style}
|
style={style}
|
||||||
aria-label={a11yLabel}
|
aria-label={a11yLabel}
|
||||||
>
|
>
|
||||||
{Array.isArray(children) ? children.map(mapMenuItems) : null}
|
{toChildArray(children).map(mapMenuItems)}
|
||||||
</menu>
|
</menu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.challenge-modal {
|
.challenge-modal {
|
||||||
max-width: 480px;
|
min-width: 0 !important;
|
||||||
min-width: 400px !important;
|
|
||||||
|
|
||||||
.prompt, .subprompt {
|
.prompt, .subprompt {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -21,9 +20,6 @@
|
|||||||
.sk-panel .sk-panel-header {
|
.sk-panel .sk-panel-header {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
input {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#account-switcher {
|
#account-switcher {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
left: 0px;
|
left: 0px;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
.challenge-modal-overlay::before {
|
||||||
|
background-color: var(--sn-stylekit-grey-5);
|
||||||
|
}
|
||||||
|
|
||||||
[data-reach-dialog-content] {
|
[data-reach-dialog-content] {
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|||||||
@@ -246,6 +246,10 @@
|
|||||||
margin-left: 0.75rem;
|
margin-left: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sn-component .mr-0 {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mr-2\.5 {
|
.mr-2\.5 {
|
||||||
margin-right: 0.625rem;
|
margin-right: 0.625rem;
|
||||||
}
|
}
|
||||||
@@ -298,6 +302,10 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mb-3\.5 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.max-w-40\% {
|
.max-w-40\% {
|
||||||
max-width: 40%;
|
max-width: 40%;
|
||||||
}
|
}
|
||||||
@@ -314,10 +322,18 @@
|
|||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.max-w-68 {
|
||||||
|
max-width: 17rem;
|
||||||
|
}
|
||||||
|
|
||||||
.max-w-72 {
|
.max-w-72 {
|
||||||
max-width: 18rem;
|
max-width: 18rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.max-w-76 {
|
||||||
|
max-width: 19rem;
|
||||||
|
}
|
||||||
|
|
||||||
.max-w-89 {
|
.max-w-89 {
|
||||||
max-width: 22.25rem;
|
max-width: 22.25rem;
|
||||||
}
|
}
|
||||||
@@ -397,6 +413,10 @@
|
|||||||
min-width: 17.5rem;
|
min-width: 17.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.min-w-76 {
|
||||||
|
min-width: 19rem;
|
||||||
|
}
|
||||||
|
|
||||||
.min-w-24 {
|
.min-w-24 {
|
||||||
min-width: 6rem;
|
min-width: 6rem;
|
||||||
}
|
}
|
||||||
@@ -457,7 +477,7 @@
|
|||||||
max-height: 27.5rem;
|
max-height: 27.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.border-danger {
|
.sn-component .border-danger {
|
||||||
border-color: var(--sn-stylekit-danger-color);
|
border-color: var(--sn-stylekit-danger-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +521,10 @@
|
|||||||
padding: 0.375rem;
|
padding: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.p-6 {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.p-8 {
|
.p-8 {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
@@ -610,6 +634,11 @@
|
|||||||
padding-bottom: 2.25rem;
|
padding-bottom: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.py-12 {
|
||||||
|
padding-top: 3rem;
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pl-2 {
|
.pl-2 {
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -626,6 +655,10 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-4 {
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.top-30\% {
|
.top-30\% {
|
||||||
top: 30%;
|
top: 30%;
|
||||||
}
|
}
|
||||||
@@ -694,6 +727,10 @@
|
|||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.right-4 {
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.-right-2 {
|
.-right-2 {
|
||||||
right: -0.5rem;
|
right: -0.5rem;
|
||||||
}
|
}
|
||||||
@@ -1054,6 +1091,10 @@
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shadow-overlay-light {
|
||||||
|
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
.sn-component .focus\:brightness-default:focus {
|
.sn-component .focus\:brightness-default:focus {
|
||||||
filter: brightness(100%);
|
filter: brightness(100%);
|
||||||
}
|
}
|
||||||
@@ -1062,6 +1103,11 @@
|
|||||||
filter: brightness(100%);
|
filter: brightness(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.appearance-none {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sn-component .focus\:bg-default:focus {
|
.sn-component .focus\:bg-default:focus {
|
||||||
background-color: var(--sn-stylekit-background-color);
|
background-color: var(--sn-stylekit-background-color);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user