(core) Enable MFA configuration (and add SMS)

Summary:
Enables configuration of multi-factor authentication from the
account page (for users who sign in with email/password), and adds
SMS as an authentication method.

Test Plan: Project, browser and server tests.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3215
pull/121/head
George Gevoian 2 years ago
parent 1b4580d92e
commit 0d005eb78d

@ -1,17 +1,5 @@
import {TopAppModelImpl} from 'app/client/models/AppModel';
import {setUpErrorHandling} from 'app/client/models/errors';
import {AccountPage} from 'app/client/ui/AccountPage';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars} from 'app/client/ui2018/cssVars';
import {setupPage} from 'app/client/ui/setupPage';
import {dom} from 'grainjs';
// Set up the global styles for variables, and root/body styles.
setUpErrorHandling();
const topAppModel = TopAppModelImpl.create(null, {});
attachCssRootVars(topAppModel.productFlavor);
addViewportTag();
dom.update(document.body, dom.maybe(topAppModel.appObs, (appModel) => [
dom.create(AccountPage, appModel),
buildSnackbarDom(appModel.notifier, appModel),
]));
setupPage((appModel) => dom.create(AccountPage, appModel));

@ -1,17 +1,4 @@
import {TopAppModelImpl} from 'app/client/models/AppModel';
import {setUpErrorHandling} from 'app/client/models/errors';
import {createErrPage} from 'app/client/ui/errorPages';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars} from 'app/client/ui2018/cssVars';
import {dom} from 'grainjs';
import {setupPage} from 'app/client/ui/setupPage';
// Set up the global styles for variables, and root/body styles.
setUpErrorHandling();
const topAppModel = TopAppModelImpl.create(null, {});
attachCssRootVars(topAppModel.productFlavor);
addViewportTag();
dom.update(document.body, dom.maybe(topAppModel.appObs, (appModel) => [
createErrPage(appModel),
buildSnackbarDom(appModel.notifier, appModel),
]));
setupPage((appModel) => createErrPage(appModel));

@ -0,0 +1,54 @@
import {reportError} from 'app/client/models/errors';
import {BaseAPI} from 'app/common/BaseAPI';
import {dom, Observable} from 'grainjs';
/**
* Handles submission of an HTML form element.
*
* When the form is submitted, `onSubmit` will be called, followed by
* either `onSuccess` or `onError`, depending on whether `onSubmit` threw any
* unhandled errors. The `pending` observable is set to true until `onSubmit`
* resolves.
*/
export function handleSubmit<T>(
pending: Observable<boolean>,
onSubmit: (fields: { [key: string]: string }, form: HTMLFormElement) => Promise<T> = submitForm,
onSuccess: (v: T) => void = () => { /* noop */ },
onError: (e: unknown) => void = (e) => reportError(e as string | Error)
): (elem: HTMLFormElement) => void {
return dom.on('submit', async (e, form) => {
e.preventDefault();
try {
if (pending.get()) { return; }
pending.set(true);
const result = await onSubmit(formDataToObj(form), form).finally(() => pending.set(false));
onSuccess(result);
} catch (err) {
onError(err);
}
});
}
/**
* Convert a form to a JSON-stringifiable object, ignoring any File fields.
*/
export function formDataToObj(formElem: HTMLFormElement): { [key: string]: string } {
// Use FormData to collect values (rather than e.g. finding <input> elements) to ensure we get
// values from all form items correctly (e.g. checkboxes and textareas).
const formData = new FormData(formElem);
const data: { [key: string]: string } = {};
for (const [name, value] of formData.entries()) {
if (typeof value === 'string') {
data[name] = value;
}
}
return data;
}
/**
* Submit a form using BaseAPI. Send inputs as JSON, and interpret any reply as JSON.
*/
export async function submitForm(fields: { [key: string]: string }, form: HTMLFormElement): Promise<any> {
return BaseAPI.requestJson(form.action, {method: 'POST', body: JSON.stringify(fields)});
}

@ -10,7 +10,6 @@
import {DocComm} from 'app/client/components/DocComm';
import {UserError} from 'app/client/models/errors';
import {FileDialogOptions, openFilePicker} from 'app/client/ui/FileDialog';
import {BaseAPI} from 'app/common/BaseAPI';
import {GristLoadConfig} from 'app/common/gristUrls';
import {byteString, safeJsonParse} from 'app/common/gutil';
import {FetchUrlOptions, UPLOAD_URL_PATH, UploadResult} from 'app/common/uploads';
@ -184,25 +183,3 @@ export function isDriveUrl(url: string) {
const match = /^https:\/\/(docs|drive).google.com\/(spreadsheets|file)\/d\/([^/]*)/i.exec(url);
return !!match;
}
/**
* Convert a form to a JSON-stringifiable object, ignoring any File fields.
*/
export function formDataToObj(formElem: HTMLFormElement): {[key: string]: string} {
// Use FormData to collect values (rather than e.g. finding <input> elements) to ensure we get
// values from all form items correctly (e.g. checkboxes and textareas).
const formData = new FormData(formElem);
const data: {[key: string]: string} = {};
for (const [name, value] of formData.entries()) {
if (typeof value === 'string') {
data[name] = value;
}
}
return data;
}
// Submit a form using BaseAPI. Send inputs as JSON, and interpret any reply as JSON.
export async function submitForm(form: HTMLFormElement): Promise<any> {
const data = formDataToObj(form);
return BaseAPI.requestJson(form.action, {method: 'POST', body: JSON.stringify(data)});
}

@ -3,7 +3,7 @@ import {getResetPwdUrl, urlState} from 'app/client/models/gristUrlState';
import {ApiKey} from 'app/client/ui/ApiKey';
import {AppHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
// import {MFAConfig} from 'app/client/ui/MFAConfig';
import {MFAConfig} from 'app/client/ui/MFAConfig';
import {pagePanels} from 'app/client/ui/PagePanels';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {transientInput} from 'app/client/ui/transientInput';
@ -13,7 +13,7 @@ import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/b
import {icon} from 'app/client/ui2018/icons';
import {cssModalBody, cssModalButtons, cssModalTitle, modal} from 'app/client/ui2018/modals';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {FullUser, /*UserMFAPreferences*/} from 'app/common/UserAPI';
import {FullUser, UserMFAPreferences} from 'app/common/UserAPI';
import {Computed, Disposable, dom, domComputed, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-account-page-');
@ -24,7 +24,7 @@ const testId = makeTestId('test-account-page-');
export class AccountPage extends Disposable {
private _apiKey = Observable.create<string>(this, '');
private _userObs = Observable.create<FullUser|null>(this, null);
// private _userMfaPreferences = Observable.create<UserMFAPreferences|null>(this, null);
private _userMfaPreferences = Observable.create<UserMFAPreferences|null>(this, null);
private _isEditingName = Observable.create(this, false);
private _nameEdit = Observable.create<string>(this, '');
private _isNameValid = Computed.create(this, this._nameEdit, (_use, val) => checkName(val));
@ -86,26 +86,24 @@ export class AccountPage extends Disposable {
cssDataRow(
cssSubHeader('Login Method'),
user.loginMethod,
// TODO: should show btn only when logged in with google
user.loginMethod === 'Email + Password' ? cssTextBtn(
// rename to remove mention of Billing in the css
cssIcon('Settings'), 'Reset',
dom.on('click', () => confirmPwdResetModal(user.email)),
) : null,
testId('login-method'),
),
// user.loginMethod !== 'Email + Password' ? null : dom.frag(
// cssSubHeaderFullWidth('Two-factor authentication'),
// cssDescription(
// "Two-factor authentication is an extra layer of security for your Grist account designed " +
// "to ensure that you're the only person who can access your account, even if someone " +
// "knows your password."
// ),
// dom.create(MFAConfig, this._userMfaPreferences, {
// user,
// appModel: this._appModel,
// }),
// ),
user.loginMethod !== 'Email + Password' ? null : dom.frag(
cssSubHeaderFullWidth('Two-factor authentication'),
cssDescription(
"Two-factor authentication is an extra layer of security for your Grist account designed " +
"to ensure that you're the only person who can access your account, even if someone " +
"knows your password."
),
dom.create(MFAConfig, this._userMfaPreferences, {
appModel: this._appModel,
onChange: () => this._fetchUserMfaPreferences(),
}),
),
cssHeader('API'),
cssDataRow(cssSubHeader('API Key'), cssContent(
dom.create(ApiKey, {
@ -152,13 +150,14 @@ export class AccountPage extends Disposable {
this._userObs.set(await this._appModel.api.getUserProfile());
}
// private async _fetchUserMfaPreferences() {
// this._userMfaPreferences.set(await this._appModel.api.getUserMfaPreferences());
// }
private async _fetchUserMfaPreferences() {
this._userMfaPreferences.set(null);
this._userMfaPreferences.set(await this._appModel.api.getUserMfaPreferences());
}
private async _fetchAll() {
await Promise.all([
// this._fetchUserMfaPreferences(),
this._fetchUserMfaPreferences(),
this._fetchApiKey(),
this._fetchUserProfile(),
]);
@ -263,7 +262,7 @@ const cssWarnings = styled(buildNameWarningsDom, `
margin: -8px 0 0 110px;
`);
// const cssDescription = styled('div', `
// color: #8a8a8a;
// font-size: 13px;
// `);
const cssDescription = styled('div', `
color: #8a8a8a;
font-size: 13px;
`);

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
import { Computed, Disposable, dom, domComputed, DomContents, input, MultiHolder, Observable, styled } from "grainjs";
import { submitForm } from "app/client/lib/uploads";
import { handleSubmit, submitForm } from "app/client/lib/formUtils";
import { AppModel, reportError } from "app/client/models/AppModel";
import { getLoginUrl, getSignupUrl, urlState } from "app/client/models/gristUrlState";
import { AccountWidget } from "app/client/ui/AccountWidget";
@ -35,29 +35,12 @@ function _redirectOnSuccess(result: any) {
window.location.assign(redirectUrl);
}
async function _submitForm(form: HTMLFormElement, pending: Observable<boolean>,
onSuccess: (v: any) => void = _redirectOnSuccess,
onError: (e: Error) => void = reportError) {
try {
if (pending.get()) { return; }
pending.set(true);
const result = await submitForm(form).finally(() => pending.set(false));
onSuccess(result);
} catch (err) {
onError(err?.details?.userError || err);
}
}
// If a 'pending' observable is given, it will be set to true while waiting for the submission.
function handleSubmit(pending: Observable<boolean>,
onSuccess?: (v: any) => void,
onError?: (e: Error) => void): (elem: HTMLFormElement) => void {
return dom.on('submit', async (e, form) => {
e.preventDefault();
// TODO: catch isn't needed, so either remove or propagate errors from _submitForm.
_submitForm(form, pending, onSuccess, onError).catch(reportError);
});
function handleSubmitForm(
pending: Observable<boolean>,
onSuccess: (v: any) => void = _redirectOnSuccess,
onError?: (e: unknown) => void
): (elem: HTMLFormElement) => void {
return handleSubmit(pending, submitForm, onSuccess, onError);
}
export class WelcomePage extends Disposable {
@ -114,13 +97,13 @@ export class WelcomePage extends Disposable {
return form = dom(
'form',
{ method: "post", action: location.href },
handleSubmit(pending),
handleSubmitForm(pending),
cssLabel('Your full name, as you\'d like it displayed to your collaborators.'),
inputEl = cssInput(
value, { onInput: true, },
{ name: "username" },
// TODO: catch isn't needed, so either remove or propagate errors from _submitForm.
dom.onKeyDown({Enter: () => isNameValid.get() && _submitForm(form, pending).catch(reportError)}),
dom.onKeyDown({Enter: () => isNameValid.get() && form.requestSubmit()}),
),
dom.maybe((use) => use(value) && !use(isNameValid), buildNameWarningsDom),
cssButtonGroup(
@ -153,7 +136,7 @@ export class WelcomePage extends Disposable {
return dom(
'form',
{ method: "post", action: action.href },
handleSubmit(pending, () => _redirectToSiblingPage('verify')),
handleSubmitForm(pending, () => _redirectToSiblingPage('verify')),
dom('p',
`Welcome Sumo-ling! ` + // This flow currently only used with AppSumo.
`Your Grist site is almost ready. Let's get your account set up and verified. ` +
@ -212,7 +195,7 @@ export class WelcomePage extends Disposable {
return dom(
'form',
{ method: "post", action: action.href },
handleSubmit(pending, (result) => {
handleSubmitForm(pending, (result) => {
if (result.act === 'confirmed') {
const verified = new URL(window.location.href);
verified.pathname = '/verified';
@ -259,7 +242,7 @@ export class WelcomePage extends Disposable {
const pending = Observable.create(owner, false);
return forms.form({method: "post", action: location.href },
handleSubmit(pending),
handleSubmitForm(pending),
(elem) => { setTimeout(() => elem.focus(), 0); },
forms.text('Please help us serve you better by answering a few questions.'),
forms.question(

@ -0,0 +1,21 @@
import {AppModel, TopAppModelImpl} from 'app/client/models/AppModel';
import {setUpErrorHandling} from 'app/client/models/errors';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars} from 'app/client/ui2018/cssVars';
import {dom, DomContents} from 'grainjs';
/**
* Sets up error handling and global styles, and replaces the DOM body with
* the result of calling `buildPage`.
*/
export function setupPage(buildPage: (appModel: AppModel) => DomContents) {
setUpErrorHandling();
const topAppModel = TopAppModelImpl.create(null, {});
attachCssRootVars(topAppModel.productFlavor);
addViewportTag();
dom.update(document.body, dom.maybe(topAppModel.appObs, (appModel) => [
buildPage(appModel),
buildSnackbarDom(appModel.notifier, appModel),
]));
}

@ -66,6 +66,8 @@ export type IconName = "ChartArea" |
"Log" |
"Mail" |
"Minus" |
"MobileChat" |
"MobileChat2" |
"NewNotification" |
"Notification" |
"Offline" |
@ -177,6 +179,8 @@ export const IconList: IconName[] = ["ChartArea",
"Log",
"Mail",
"Minus",
"MobileChat",
"MobileChat2",
"NewNotification",
"Notification",
"Offline",

@ -271,6 +271,8 @@ export interface DocStateComparisonDetails {
*/
export interface UserMFAPreferences {
isSmsMfaEnabled: boolean;
// If SMS MFA is enabled, the destination number for receiving verification codes.
phoneNumber?: string;
isSoftwareTokenMfaEnabled: boolean;
}
@ -278,10 +280,42 @@ export interface UserMFAPreferences {
* Cognito response to initiating software token MFA registration.
*/
export interface SoftwareTokenRegistrationInfo {
session: string;
secretCode: string;
}
/**
* Cognito response to initiating SMS MFA registration.
*/
export interface SMSRegistrationInfo {
deliveryDestination: string;
}
/**
* Cognito response to verifying a password (e.g. in a security verification form).
*/
export type PassVerificationResult = ChallengeRequired | ChallengeNotRequired;
/**
* Information about the follow-up authentication challenge.
*/
interface ChallengeRequired {
isChallengeRequired: true;
// Session identifier that must be re-used in response to auth challenge.
session: string;
challengeName: 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA';
// If challenge is 'SMS_MFA', the destination number that the verification code was sent.
deliveryDestination?: string;
}
/**
* Successful authentication, with no additional challenge required.
*/
interface ChallengeNotRequired {
isChallengeRequired: false;
}
export type AuthMethod = 'TOTP' | 'SMS';
export {UserProfile} from 'app/common/LoginSessionAPI';
export interface UserAPI {
@ -338,7 +372,13 @@ export interface UserAPI {
}): Promise<string>;
deleteUser(userId: number, name: string): Promise<void>;
registerSoftwareToken(): Promise<SoftwareTokenRegistrationInfo>;
confirmRegisterSoftwareToken(verificationCode: string): Promise<void>;
unregisterSoftwareToken(): Promise<void>;
registerSMS(phoneNumber: string): Promise<SMSRegistrationInfo>;
confirmRegisterSMS(verificationCode: string): Promise<void>;
unregisterSMS(): Promise<void>;
verifyPassword(password: string, preferredMfaMethod?: AuthMethod): Promise<PassVerificationResult>;
verifySecondStep(authMethod: AuthMethod, verificationCode: string, session: string): Promise<void>;
getBaseUrl(): string; // Get the prefix for all the endpoints this object wraps.
forRemoved(): UserAPI; // Get a version of the API that works on removed resources.
getWidgets(): Promise<ICustomWidget[]>;
@ -695,10 +735,53 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
return this.requestJson(`${this._url}/api/auth/register_totp`, {method: 'POST'});
}
public async confirmRegisterSoftwareToken(verificationCode: string): Promise<void> {
await this.request(`${this._url}/api/auth/confirm_register_totp`, {
method: 'POST',
body: JSON.stringify({verificationCode}),
});
}
public async unregisterSoftwareToken(): Promise<void> {
await this.request(`${this._url}/api/auth/unregister_totp`, {method: 'POST'});
}
public async registerSMS(phoneNumber: string): Promise<SMSRegistrationInfo> {
return this.requestJson(`${this._url}/api/auth/register_sms`, {
method: 'POST',
body: JSON.stringify({phoneNumber}),
});
}
public async confirmRegisterSMS(verificationCode: string): Promise<void> {
await this.request(`${this._url}/api/auth/confirm_register_sms`, {
method: 'POST',
body: JSON.stringify({verificationCode}),
});
}
public async unregisterSMS(): Promise<void> {
await this.request(`${this._url}/api/auth/unregister_sms`, {method: 'POST'});
}
public async verifyPassword(password: string, preferredMfaMethod?: AuthMethod): Promise<any> {
return this.requestJson(`${this._url}/api/auth/verify_pass`, {
method: 'POST',
body: JSON.stringify({password, preferredMfaMethod}),
});
}
public async verifySecondStep(
authMethod: AuthMethod,
verificationCode: string,
session: string
): Promise<void> {
await this.request(`${this._url}/api/auth/verify_second_step`, {
method: 'POST',
body: JSON.stringify({authMethod, verificationCode, session}),
});
}
public getBaseUrl(): string { return this._url; }
// Recomputes the URL on every call to pick up changes in the URL when switching orgs.

@ -67,6 +67,8 @@
--icon-Log: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSw4LjUgTDYuNSw4LjUgQzYuMjIzODU3NjMsOC41IDYsOC4yNzYxNDIzNyA2LDggQzYsNy43MjM4NTc2MyA2LjIyMzg1NzYzLDcuNSA2LjUsNy41IEw3LjUsNy41IEw3LjUsMC41IEM3LjUsMC4yMjM4NTc2MjUgNy43MjM4NTc2MywwIDgsMCBDOC4yNzYxNDIzNywwIDguNSwwLjIyMzg1NzYyNSA4LjUsMC41IEw4LjUsMyBMOS41LDMgQzkuNzc2MTQyMzcsMyAxMCwzLjIyMzg1NzYzIDEwLDMuNSBDMTAsMy43NzYxNDIzNyA5Ljc3NjE0MjM3LDQgOS41LDQgTDguNSw0IEw4LjUsMTIgTDkuNSwxMiBDOS43NzYxNDIzNywxMiAxMCwxMi4yMjM4NTc2IDEwLDEyLjUgQzEwLDEyLjc3NjE0MjQgOS43NzYxNDIzNywxMyA5LjUsMTMgTDguNSwxMyBMOC41LDE1LjUgQzguNSwxNS43NzYxNDI0IDguMjc2MTQyMzcsMTYgOCwxNiBDNy43MjM4NTc2MywxNiA3LjUsMTUuNzc2MTQyNCA3LjUsMTUuNSBMNy41LDguNSBaIE0xMiwyIEwxMiw1IEwxNSw1IEwxNSwyIEwxMiwyIFogTTExLjUsMSBMMTUuNSwxIEMxNS43NzYxNDI0LDEgMTYsMS4yMjM4NTc2MyAxNiwxLjUgTDE2LDUuNSBDMTYsNS43NzYxNDIzNyAxNS43NzYxNDI0LDYgMTUuNSw2IEwxMS41LDYgQzExLjIyMzg1NzYsNiAxMSw1Ljc3NjE0MjM3IDExLDUuNSBMMTEsMS41IEMxMSwxLjIyMzg1NzYzIDExLjIyMzg1NzYsMSAxMS41LDEgWiBNMTIsMTEgTDEyLDE0IEwxNSwxNCBMMTUsMTEgTDEyLDExIFogTTExLjUsMTAgTDE1LjUsMTAgQzE1Ljc3NjE0MjQsMTAgMTYsMTAuMjIzODU3NiAxNiwxMC41IEwxNiwxNC41IEMxNiwxNC43NzYxNDI0IDE1Ljc3NjE0MjQsMTUgMTUuNSwxNSBMMTEuNSwxNSBDMTEuMjIzODU3NiwxNSAxMSwxNC43NzYxNDI0IDExLDE0LjUgTDExLDEwLjUgQzExLDEwLjIyMzg1NzYgMTEuMjIzODU3NiwxMCAxMS41LDEwIFogTTQsOS41IEw0LDYuNSBMMSw2LjUgTDEsOS41IEw0LDkuNSBaIE00LjUsMTAuNSBMMC41LDEwLjUgQzAuMjIzODU3NjI1LDEwLjUgMCwxMC4yNzYxNDI0IDAsMTAgTDguODgxNzg0MmUtMTYsNiBDOC44ODE3ODQyZS0xNiw1LjcyMzg1NzYzIDAuMjIzODU3NjI1LDUuNSAwLjUsNS41IEw0LjUsNS41IEM0Ljc3NjE0MjM3LDUuNSA1LDUuNzIzODU3NjMgNSw2IEw1LDEwIEM1LDEwLjI3NjE0MjQgNC43NzYxNDIzNywxMC41IDQuNSwxMC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Mail: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjUsMTMgQzE0Ljc3NTg1NzYsMTMgMTUsMTIuNzc1ODU3NiAxNSwxMi41IEwxNSwxLjUgQzE1LDEuMjI0MTQyMzcgMTQuNzc1ODU3NiwxIDE0LjUsMSBMMS41LDEgQzEuMjI0MTQyMzcsMSAxLDEuMjI0MTQyMzcgMSwxLjUgTDEsMTIuNSBDMSwxMi43NzU4NTc2IDEuMjI0MTQyMzcsMTMgMS41LDEzIEwxNC41LDEzIFogTTE0LjUsMTQgTDEuNSwxNCBDMC42NzE4NTc2MjUsMTQgMCwxMy4zMjgxNDI0IDAsMTIuNSBMMCwxLjUgQzAsMC42NzE4NTc2MjUgMC42NzE4NTc2MjUsOC44ODE3ODQyZS0xNiAxLjUsOC44ODE3ODQyZS0xNiBMMTQuNSw4Ljg4MTc4NDJlLTE2IEMxNS4zMjgxNDI0LDguODgxNzg0MmUtMTYgMTYsMC42NzE4NTc2MjUgMTYsMS41IEwxNiwxMi41IEMxNiwxMy4zMjgxNDI0IDE1LjMyODE0MjQsMTQgMTQuNSwxNCBaIE0xMy4xODMzODExLDMuMTEzMDIxMzUgQzEzLjM5NzEwMzUsMi45MzgxNTc1NiAxMy43MTIxMTQ5LDIuOTY5NjU4NyAxMy44ODY5Nzg2LDMuMTgzMzgxMSBDMTQuMDYxODQyNCwzLjM5NzEwMzUxIDE0LjAzMDM0MTMsMy43MTIxMTQ4NiAxMy44MTY2MTg5LDMuODg2OTc4NjUgTDguMzE2NjE4OSw4LjM4Njk3ODY1IEM4LjEzMjQzNTk1LDguNTM3NjczNzggNy44Njc1NjQwNSw4LjUzNzY3Mzc4IDcuNjgzMzgxMSw4LjM4Njk3ODY1IEwyLjE4MzM4MTEsMy44ODY5Nzg2NSBDMS45Njk2NTg3LDMuNzEyMTE0ODYgMS45MzgxNTc1NiwzLjM5NzEwMzUxIDIuMTEzMDIxMzUsMy4xODMzODExIEMyLjI4Nzg4NTE0LDIuOTY5NjU4NyAyLjYwMjg5NjQ5LDIuOTM4MTU3NTYgMi44MTY2MTg5LDMuMTEzMDIxMzUgTDgsNy4zNTM5Njk1MyBMMTMuMTgzMzgxMSwzLjExMzAyMTM1IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Minus: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSIyIiB5PSI3LjUiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxIiByeD0iLjUiLz48L3N2Zz4=');
--icon-MobileChat: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjUgOC41VjE0LjVDMTEuNSAxNC43NjUyIDExLjM5NDYgMTUuMDE5NiAxMS4yMDcxIDE1LjIwNzFDMTEuMDE5NiAxNS4zOTQ2IDEwLjc2NTIgMTUuNSAxMC41IDE1LjVIMi41QzIuMjM0NzggMTUuNSAxLjk4MDQzIDE1LjM5NDYgMS43OTI4OSAxNS4yMDcxQzEuNjA1MzYgMTUuMDE5NiAxLjUgMTQuNzY1MiAxLjUgMTQuNVYyLjVDMS41IDIuMjM0NzggMS42MDUzNiAxLjk4MDQzIDEuNzkyODkgMS43OTI4OUMxLjk4MDQzIDEuNjA1MzYgMi4yMzQ3OCAxLjUgMi41IDEuNUg0LjUiIHN0cm9rZT0iIzI2MjYzMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE0LjUgNi41SDkuNUw2LjUgOC41VjEuNUM2LjUgMS4yMzQ3OCA2LjYwNTM2IDAuOTgwNDMgNi43OTI4OSAwLjc5Mjg5M0M2Ljk4MDQzIDAuNjA1MzU3IDcuMjM0NzggMC41IDcuNSAwLjVIMTQuNUMxNC43NjUyIDAuNSAxNS4wMTk2IDAuNjA1MzU3IDE1LjIwNzEgMC43OTI4OTNDMTUuMzk0NiAwLjk4MDQzIDE1LjUgMS4yMzQ3OCAxNS41IDEuNVY1LjVDMTUuNSA1Ljc2NTIyIDE1LjM5NDYgNi4wMTk1NyAxNS4yMDcxIDYuMjA3MTFDMTUuMDE5NiA2LjM5NDY0IDE0Ljc2NTIgNi41IDE0LjUgNi41WiIgc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
--icon-MobileChat2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTggMEgxNUMxNS4yNjUyIDAgMTUuNTE5NiAwLjEwNTM1NyAxNS43MDcxIDAuMjkyODkzQzE1Ljg5NDYgMC40ODA0MyAxNiAwLjczNDc4NCAxNiAxVjVDMTYgNS4yNjUyMiAxNS44OTQ2IDUuNTE5NTcgMTUuNzA3MSA1LjcwNzExQzE1LjUxOTYgNS44OTQ2NCAxNS4yNjUyIDYgMTUgNkgxMEw3IDhWMUM3IDAuNzM0Nzg0IDcuMTA1MzYgMC40ODA0MyA3LjI5Mjg5IDAuMjkyODkzQzcuNDgwNDMgMC4xMDUzNTcgNy43MzQ3OCAwIDggMFYwWiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik0xMCA3VjEzSDNWNEg2VjFIM0MyLjQ2OTU3IDEgMS45NjA4NiAxLjIxMDcxIDEuNTg1NzkgMS41ODU3OUMxLjIxMDcxIDEuOTYwODYgMSAyLjQ2OTU3IDEgM1YxNEMxIDE0LjUzMDQgMS4yMTA3MSAxNS4wMzkxIDEuNTg1NzkgMTUuNDE0MkMxLjk2MDg2IDE1Ljc4OTMgMi40Njk1NyAxNiAzIDE2SDEwQzEwLjUzMDQgMTYgMTEuMDM5MSAxNS43ODkzIDExLjQxNDIgMTUuNDE0MkMxMS43ODkzIDE1LjAzOTEgMTIgMTQuNTMwNCAxMiAxNFY3SDEwWiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg==');
--icon-NewNotification: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIGN4PSI4IiBjeT0iOCIgcj0iMyIvPjwvc3ZnPg==');
--icon-Notification: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEuNSwxMSBDMi4zMjg4NTc2MywxMSAzLDEwLjMyODg1NzYgMyw5LjUgTDMsNSBDMywyLjIzODg1NzYzIDUuMjM4ODU3NjMsMCA4LDAgQzEwLjc2MTE0MjQsMCAxMywyLjIzODg1NzYzIDEzLDUgTDEzLDkuNSBDMTMsMTAuMjIwMzMyNCAxNC4wNDg5ODI4LDExIDE1LDExIEwxNS41LDExIEMxNS43NzYxNDI0LDExIDE2LDExLjIyMzg1NzYgMTYsMTEuNSBDMTYsMTEuNzc2MTQyNCAxNS43NzYxNDI0LDEyIDE1LjUsMTIgTDAuNSwxMiBDMC4yMjM4NTc2MjUsMTIgMCwxMS43NzYxNDI0IDAsMTEuNSBDMCwxMS4yMjM4NTc2IDAuMjIzODU3NjI1LDExIDAuNSwxMSBMMS41MDAwMDAyNiwxMSBaIE0zLjUwMDQ0ODk0LDExIEwxMi42NzQ1Njg5LDExIEMxMi4yNjMzODg1LDEwLjU3NzkwMjggMTIsMTAuMDU1NTcxOCAxMiw5LjUgTDEyLDUgQzEyLDIuNzkxMTQyMzcgMTAuMjA4ODU3NiwxIDgsMSBDNS43OTExNDIzNywxIDQsMi43OTExNDIzNyA0LDUgTDQsOS41IEM0LDEwLjA2Mjg5NTUgMy44MTQxNTM5MSwxMC41ODIyMjQ1IDMuNTAwNDQ4OTQsMTEgWiBNOS41LDEzLjUgQzkuNSwxMy4yMjM4NTc2IDkuNzIzODU3NjMsMTMgMTAsMTMgQzEwLjI3NjE0MjQsMTMgMTAuNSwxMy4yMjM4NTc2IDEwLjUsMTMuNSBDMTAuNSwxNC44ODExNDI0IDkuMzgxMTQyMzcsMTYgOCwxNiBDNi42MTg4NTc2MywxNiA1LjUsMTQuODgxMTQyNCA1LjUsMTMuNSBDNS41LDEzLjIyMzg1NzYgNS43MjM4NTc2MywxMyA2LDEzIEM2LjI3NjE0MjM3LDEzIDYuNSwxMy4yMjM4NTc2IDYuNSwxMy41IEM2LjUsMTQuMzI4ODU3NiA3LjE3MTE0MjM3LDE1IDgsMTUgQzguODI4ODU3NjMsMTUgOS41LDE0LjMyODg1NzYgOS41LDEzLjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-Offline: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkuMTc1MzI0MDksNy4xMTc1NjkxMyBMMTEuNTU5NTE0Nyw0LjczMzM3ODUgQzguMjc0NDE0MjEsMy4zMTk2ODAxNCA0LjMxODgyNzQ1LDMuOTU0MDg5NTYgMS42MzU1LDYuNjM2NjA2NzcgQzEuNDQwMjA4MzcsNi44MzE4Mzk0MyAxLjEyMzYyNTg5LDYuODMxNzkxNjMgMC45MjgzOTMyMjcsNi42MzY1IEMwLjczMzE2MDU2OCw2LjQ0MTIwODM3IDAuNzMzMjA4MzcyLDYuMTI0NjI1ODkgMC45Mjg1LDUuOTI5MzkzMjMgQzQuMDA4NTkyMjksMi44NTAyMzA5OSA4LjU5NzM2ODQ3LDIuMTk5Nzc3NDUgMTIuMzE0ODYwNiwzLjk3ODAzMjYxIEwxNC4xNDY0NDY2LDIuMTQ2NDQ2NjEgQzE0LjM0MTcwODgsMS45NTExODQ0NiAxNC42NTgyOTEyLDEuOTUxMTg0NDYgMTQuODUzNTUzNCwyLjE0NjQ0NjYxIEMxNS4wNDg4MTU1LDIuMzQxNzA4NzYgMTUuMDQ4ODE1NSwyLjY1ODI5MTI0IDE0Ljg1MzU1MzQsMi44NTM1NTMzOSBMMTMuMjMwNDA4LDQuNDc2Njk4NzUgQzEzLjg4NDI3OTksNC44Nzg3NDgwNCAxNC41MDE5MTUyLDUuMzYyOTc5NTMgMTUuMDY4NSw1LjkyOTM5MzIzIEMxNS4yNjM3OTE2LDYuMTI0NjI1ODkgMTUuMjYzODM5NCw2LjQ0MTIwODM3IDE1LjA2ODYwNjgsNi42MzY1IEMxNC44NzMzNzQxLDYuODMxNzkxNjMgMTQuNTU2NzkxNiw2LjgzMTgzOTQzIDE0LjM2MTUsNi42MzY2MDY3NyBDMTMuNzkyMzczOSw2LjA2NzY1MjQ4IDEzLjE2NjAxNDksNS41OTA4MzI3NCAxMi41MDA5NTkyLDUuMjA2MTQ3NTYgTDEwLjI2MzI2MjIsNy40NDM4NDQ1NSBDMTAuOTgyMjA2Miw3LjczNjQ0NTI2IDExLjY1NTg5NTIsOC4xNzQyNjQ1MyAxMi4yMzk0MDkxLDguNzU3MzAyMzcgQzEyLjQzNDc1MDksOC45NTI0ODQ4MiAxMi40MzQ4ODAxLDkuMjY5MDY3MjggMTIuMjM5Njk3Niw5LjQ2NDQwOTA5IEMxMi4wNDQ1MTUyLDkuNjU5NzUwOSAxMS43Mjc5MzI3LDkuNjU5ODgwMDggMTEuNTMyNTkwOSw5LjQ2NDY5NzYzIEMxMC45MzgzNDY0LDguODcwOTM3ODcgMTAuMjMxODc1MSw4LjQ1NzkwODYgOS40ODE0OTY5Niw4LjIyNTYwOTgyIEwyLjg1MzU1MzM5LDE0Ljg1MzU1MzQgQzIuNjU4MjkxMjQsMTUuMDQ4ODE1NSAyLjM0MTcwODc2LDE1LjA0ODgxNTUgMi4xNDY0NDY2MSwxNC44NTM1NTM0IEMxLjk1MTE4NDQ2LDE0LjY1ODI5MTIgMS45NTExODQ0NiwxNC4zNDE3MDg4IDIuMTQ2NDQ2NjEsMTQuMTQ2NDQ2NiBMOC4yODMxNTk2Myw4LjAwOTczMzU5IEM2LjkxMTA1MTE1LDcuOTMxOTY4MiA1LjUxMzAwNTkzLDguNDE2OTU2MjIgNC40NjQ0MDkwOSw5LjQ2NDY5NzYzIEM0LjI2OTA2NzI4LDkuNjU5ODgwMDggMy45NTI0ODQ4Miw5LjY1OTc1MDkgMy43NTczMDIzNyw5LjQ2NDQwOTA5IEMzLjU2MjExOTkyLDkuMjY5MDY3MjggMy41NjIyNDkxLDguOTUyNDg0ODIgMy43NTc1OTA5MSw4Ljc1NzMwMjM3IEM1LjIyOTkyNzgsNy4yODYxNjY1OSA3LjI3NjM3OTkzLDYuNzM5NTg4ODQgOS4xNzUzMjQwOSw3LjExNzU2OTEzIFogTTguNjY2NjY2NTYsMTIuMjUzNjc3OSBDOC40NjA4NDIyNCwxMi4wNjk1ODI5IDguNDQzMjI3MTIsMTEuNzUzNDkwOSA4LjYyNzMyMjEsMTEuNTQ3NjY2NiBDOC44MTE0MTcwNywxMS4zNDE4NDIyIDkuMTI3NTA5MTEsMTEuMzI0MjI3MSA5LjMzMzMzMzQ0LDExLjUwODMyMjEgQzEwLjAxODIxODMsMTIuMTIwOTAyMSAxMC4xOTc1ODA3LDEzLjEyMTM0MTYgOS43NjgxNjM0NywxMy45MzM2OTc3IEM5LjMzODc0NjIyLDE0Ljc0NjA1MzggOC40MTEwNDYzMiwxNS4xNjEyOTIzIDcuNTE5MTMxMjIsMTQuOTQwMzY0NiBDNi42MjcyMTYxMywxNC43MTk0MzY4IDYuMDAwNTkyMDMsMTMuOTE5MTkxNyA2LjAwMDAwMDEsMTMuMDAwMzIyMSBDNS45OTk4MjIyMSwxMi43MjQxNzk4IDYuMjIzNTM1NTksMTIuNTAwMTc4IDYuNDk5Njc3OSwxMi41MDAwMDAxIEM2Ljc3NTgyMDIyLDEyLjQ5OTgyMjIgNi45OTk4MjIwMSwxMi43MjM1MzU2IDYuOTk5OTk5OSwxMi45OTk2Nzc5IEM3LjAwMDI5NTg2LDEzLjQ1OTExMjcgNy4zMTM2MDc5MSwxMy44NTkyMzUzIDcuNzU5NTY1NDYsMTMuOTY5Njk5MSBDOC4yMDU1MjMsMTQuMDgwMTYzIDguNjY5MzcyOTUsMTMuODcyNTQzOCA4Ljg4NDA4MTU4LDEzLjQ2NjM2NTcgQzkuMDk4NzkwMiwxMy4wNjAxODc3IDkuMDA5MTA5MDEsMTIuNTU5OTY3OSA4LjY2NjY2NjU2LDEyLjI1MzY3NzkgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 8.5V14.5C11.5 14.7652 11.3946 15.0196 11.2071 15.2071C11.0196 15.3946 10.7652 15.5 10.5 15.5H2.5C2.23478 15.5 1.98043 15.3946 1.79289 15.2071C1.60536 15.0196 1.5 14.7652 1.5 14.5V2.5C1.5 2.23478 1.60536 1.98043 1.79289 1.79289C1.98043 1.60536 2.23478 1.5 2.5 1.5H4.5" stroke="#262633" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.5 6.5H9.5L6.5 8.5V1.5C6.5 1.23478 6.60536 0.98043 6.79289 0.792893C6.98043 0.605357 7.23478 0.5 7.5 0.5H14.5C14.7652 0.5 15.0196 0.605357 15.2071 0.792893C15.3946 0.98043 15.5 1.23478 15.5 1.5V5.5C15.5 5.76522 15.3946 6.01957 15.2071 6.20711C15.0196 6.39464 14.7652 6.5 14.5 6.5Z" stroke="#262633" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 812 B

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 0H15C15.2652 0 15.5196 0.105357 15.7071 0.292893C15.8946 0.48043 16 0.734784 16 1V5C16 5.26522 15.8946 5.51957 15.7071 5.70711C15.5196 5.89464 15.2652 6 15 6H10L7 8V1C7 0.734784 7.10536 0.48043 7.29289 0.292893C7.48043 0.105357 7.73478 0 8 0V0Z" fill="#16B378"/>
<path d="M10 7V13H3V4H6V1H3C2.46957 1 1.96086 1.21071 1.58579 1.58579C1.21071 1.96086 1 2.46957 1 3V14C1 14.5304 1.21071 15.0391 1.58579 15.4142C1.96086 15.7893 2.46957 16 3 16H10C10.5304 16 11.0391 15.7893 11.4142 15.4142C11.7893 15.0391 12 14.5304 12 14V7H10Z" fill="#16B378"/>
</svg>

After

Width:  |  Height:  |  Size: 658 B

Loading…
Cancel
Save