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 { SessionsModal } from '@/components/SessionsModal';
|
||||
import { PreferencesViewWrapper } from '@/components/Preferences/PreferencesViewWrapper';
|
||||
import { ChallengeModal } from '@/components/ChallengeModal';
|
||||
import { ChallengeModal } from '@/components/ChallengeModal/ChallengeModal';
|
||||
import { NotesContextMenu } from '@/components/NotesContextMenu';
|
||||
import { PurchaseFlowWrapper } from '@/components/PurchaseFlow/PurchaseFlowWrapper';
|
||||
import { render } from 'preact';
|
||||
|
||||
@@ -22,8 +22,8 @@ const getClassName = (
|
||||
|
||||
let focusHoverStates =
|
||||
variant === 'normal'
|
||||
? 'focus:bg-contrast hover:bg-contrast'
|
||||
: 'hover:brightness-130 focus:brightness-130';
|
||||
? 'focus:bg-contrast focus:outline-none hover:bg-contrast'
|
||||
: 'hover:brightness-130 focus:outline-none focus:brightness-130';
|
||||
|
||||
if (danger) {
|
||||
colors =
|
||||
@@ -39,8 +39,8 @@ const getClassName = (
|
||||
: 'bg-grey-2 color-info-contrast';
|
||||
focusHoverStates =
|
||||
variant === 'normal'
|
||||
? 'focus:bg-default hover:bg-default'
|
||||
: 'focus:brightness-default hover:brightness-default';
|
||||
? 'focus:bg-default focus:outline-none hover:bg-default'
|
||||
: 'focus:brightness-default focus:outline-none hover:brightness-default';
|
||||
}
|
||||
|
||||
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 ${
|
||||
!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' : ''
|
||||
} ${hasRightDecorations ? 'pr-2' : ''}`,
|
||||
disabled: 'bg-grey-5 cursor-not-allowed',
|
||||
|
||||
@@ -9,7 +9,7 @@ const Toggle: FunctionComponent<{
|
||||
setIsToggled: StateUpdater<boolean>;
|
||||
}> = ({ isToggled, setIsToggled }) => (
|
||||
<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'}
|
||||
iconClassName="sn-icon--small"
|
||||
title="Show/hide password"
|
||||
|
||||
@@ -44,6 +44,7 @@ export const IconButton: FunctionComponent<Props> = ({
|
||||
const focusableClass = focusable ? '' : 'focus:shadow-none';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
className={`no-border cursor-pointer bg-transparent flex flex-row items-center hover:brightness-130 p-0 ${focusableClass} ${className}`}
|
||||
onClick={click}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
VNode,
|
||||
RefCallback,
|
||||
ComponentChild,
|
||||
toChildArray,
|
||||
} from 'preact';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
@@ -117,7 +118,7 @@ export const Menu: FunctionComponent<MenuProps> = ({
|
||||
style={style}
|
||||
aria-label={a11yLabel}
|
||||
>
|
||||
{Array.isArray(children) ? children.map(mapMenuItems) : null}
|
||||
{toChildArray(children).map(mapMenuItems)}
|
||||
</menu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
}
|
||||
|
||||
.challenge-modal {
|
||||
max-width: 480px;
|
||||
min-width: 400px !important;
|
||||
min-width: 0 !important;
|
||||
|
||||
.prompt, .subprompt {
|
||||
text-align: center;
|
||||
@@ -21,9 +20,6 @@
|
||||
.sk-panel .sk-panel-header {
|
||||
justify-content: center;
|
||||
}
|
||||
input {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#account-switcher {
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
left: 0px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.challenge-modal-overlay::before {
|
||||
background-color: var(--sn-stylekit-grey-5);
|
||||
}
|
||||
|
||||
[data-reach-dialog-content] {
|
||||
width: auto;
|
||||
|
||||
@@ -246,6 +246,10 @@
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.sn-component .mr-0 {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.mr-2\.5 {
|
||||
margin-right: 0.625rem;
|
||||
}
|
||||
@@ -298,6 +302,10 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-3\.5 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.max-w-40\% {
|
||||
max-width: 40%;
|
||||
}
|
||||
@@ -314,10 +322,18 @@
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.max-w-68 {
|
||||
max-width: 17rem;
|
||||
}
|
||||
|
||||
.max-w-72 {
|
||||
max-width: 18rem;
|
||||
}
|
||||
|
||||
.max-w-76 {
|
||||
max-width: 19rem;
|
||||
}
|
||||
|
||||
.max-w-89 {
|
||||
max-width: 22.25rem;
|
||||
}
|
||||
@@ -397,6 +413,10 @@
|
||||
min-width: 17.5rem;
|
||||
}
|
||||
|
||||
.min-w-76 {
|
||||
min-width: 19rem;
|
||||
}
|
||||
|
||||
.min-w-24 {
|
||||
min-width: 6rem;
|
||||
}
|
||||
@@ -457,7 +477,7 @@
|
||||
max-height: 27.5rem;
|
||||
}
|
||||
|
||||
.border-danger {
|
||||
.sn-component .border-danger {
|
||||
border-color: var(--sn-stylekit-danger-color);
|
||||
}
|
||||
|
||||
@@ -501,6 +521,10 @@
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.p-6 {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.p-8 {
|
||||
padding: 2rem;
|
||||
}
|
||||
@@ -610,6 +634,11 @@
|
||||
padding-bottom: 2.25rem;
|
||||
}
|
||||
|
||||
.py-12 {
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.pl-2 {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
@@ -626,6 +655,10 @@
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.top-4 {
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.top-30\% {
|
||||
top: 30%;
|
||||
}
|
||||
@@ -694,6 +727,10 @@
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
.right-4 {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.-right-2 {
|
||||
right: -0.5rem;
|
||||
}
|
||||
@@ -1054,6 +1091,10 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.shadow-overlay-light {
|
||||
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sn-component .focus\:brightness-default:focus {
|
||||
filter: brightness(100%);
|
||||
}
|
||||
@@ -1062,6 +1103,11 @@
|
||||
filter: brightness(100%);
|
||||
}
|
||||
|
||||
.appearance-none {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.sn-component .focus\:bg-default:focus {
|
||||
background-color: var(--sn-stylekit-background-color);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user