mirror of
https://github.com/tutao/tutanota.git
synced 2025-12-08 06:09:50 +00:00
Password check before adding or deleting SecondFactorAuthentication
Add SystemModel 117 Close #5986 Co-authored-by: Willow <ivk@tutao.de>
This commit is contained in:
parent
ff2ca8aeae
commit
ddb27867df
25 changed files with 1265 additions and 835 deletions
|
|
@ -530,6 +530,21 @@
|
|||
"info": "AddValue TutanotaProperties/defaultLabelCreated/1510."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": 79,
|
||||
"changes": [
|
||||
{
|
||||
"name": "AddAssociation",
|
||||
"sourceType": "MailBox",
|
||||
"info": "AddAssociation MailBox/importedAttachments/LIST_ASSOCIATION/1512."
|
||||
},
|
||||
{
|
||||
"name": "AddAssociation",
|
||||
"sourceType": "MailBox",
|
||||
"info": "AddAssociation MailBox/mailImportStates/LIST_ASSOCIATION/1578."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
EntityRestClientEraseOptions,
|
||||
EntityRestClientLoadOptions,
|
||||
EntityRestClientSetupOptions,
|
||||
EntityRestClientUpdateOptions,
|
||||
|
|
@ -112,8 +113,8 @@ export class EntityClient {
|
|||
return this._target.update(instance, options)
|
||||
}
|
||||
|
||||
erase<T extends SomeEntity>(instance: T): Promise<void> {
|
||||
return this._target.erase(instance)
|
||||
erase<T extends SomeEntity>(instance: T, options?: EntityRestClientEraseOptions): Promise<void> {
|
||||
return this._target.erase(instance, options)
|
||||
}
|
||||
|
||||
async loadRoot<T extends ElementEntity>(typeRef: TypeRef<T>, groupId: Id, opts: EntityRestClientLoadOptions = {}): Promise<T> {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const modelInfo = {
|
||||
version: 115,
|
||||
version: 117,
|
||||
compatibleSince: 114,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ import { UpgradePriceServiceDataTypeRef } from "./TypeRefs.js"
|
|||
import { UpgradePriceServiceReturnTypeRef } from "./TypeRefs.js"
|
||||
import { UserGroupKeyRotationPostInTypeRef } from "./TypeRefs.js"
|
||||
import { UserDataDeleteTypeRef } from "./TypeRefs.js"
|
||||
import { VerifierTokenServiceInTypeRef } from "./TypeRefs.js"
|
||||
import { VerifierTokenServiceOutTypeRef } from "./TypeRefs.js"
|
||||
import { VersionDataTypeRef } from "./TypeRefs.js"
|
||||
import { VersionReturnTypeRef } from "./TypeRefs.js"
|
||||
|
||||
|
|
@ -540,6 +542,15 @@ export const UserService = Object.freeze({
|
|||
delete: { data: UserDataDeleteTypeRef, return: null },
|
||||
} as const)
|
||||
|
||||
export const VerifierTokenService = Object.freeze({
|
||||
app: "sys",
|
||||
name: "VerifierTokenService",
|
||||
get: null,
|
||||
post: { data: VerifierTokenServiceInTypeRef, return: VerifierTokenServiceOutTypeRef },
|
||||
put: null,
|
||||
delete: null,
|
||||
} as const)
|
||||
|
||||
export const VersionService = Object.freeze({
|
||||
app: "sys",
|
||||
name: "VersionService",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3460,6 +3460,30 @@ export type VariableExternalAuthInfo = {
|
|||
loggedInVerifier: null | Uint8Array;
|
||||
sentCount: NumberString;
|
||||
}
|
||||
export const VerifierTokenServiceInTypeRef: TypeRef<VerifierTokenServiceIn> = new TypeRef("sys", "VerifierTokenServiceIn")
|
||||
|
||||
export function createVerifierTokenServiceIn(values: StrippedEntity<VerifierTokenServiceIn>): VerifierTokenServiceIn {
|
||||
return Object.assign(create(typeModels.VerifierTokenServiceIn, VerifierTokenServiceInTypeRef), values)
|
||||
}
|
||||
|
||||
export type VerifierTokenServiceIn = {
|
||||
_type: TypeRef<VerifierTokenServiceIn>;
|
||||
|
||||
_format: NumberString;
|
||||
authVerifier: Uint8Array;
|
||||
}
|
||||
export const VerifierTokenServiceOutTypeRef: TypeRef<VerifierTokenServiceOut> = new TypeRef("sys", "VerifierTokenServiceOut")
|
||||
|
||||
export function createVerifierTokenServiceOut(values: StrippedEntity<VerifierTokenServiceOut>): VerifierTokenServiceOut {
|
||||
return Object.assign(create(typeModels.VerifierTokenServiceOut, VerifierTokenServiceOutTypeRef), values)
|
||||
}
|
||||
|
||||
export type VerifierTokenServiceOut = {
|
||||
_type: TypeRef<VerifierTokenServiceOut>;
|
||||
|
||||
_format: NumberString;
|
||||
token: string;
|
||||
}
|
||||
export const VerifyRegistrationCodeDataTypeRef: TypeRef<VerifyRegistrationCodeData> = new TypeRef("sys", "VerifyRegistrationCodeData")
|
||||
|
||||
export function createVerifyRegistrationCodeData(values: StrippedEntity<VerifyRegistrationCodeData>): VerifyRegistrationCodeData {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const modelInfo = {
|
||||
version: 78,
|
||||
compatibleSince: 77,
|
||||
version: 79,
|
||||
compatibleSince: 79,
|
||||
}
|
||||
|
||||
export default modelInfo
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1229,7 +1229,9 @@ export type MailBox = {
|
|||
archivedMailBags: MailBag[];
|
||||
currentMailBag: null | MailBag;
|
||||
folders: null | MailFolderRef;
|
||||
importedAttachments: Id;
|
||||
mailDetailsDrafts: null | MailDetailsDraftsRef;
|
||||
mailImportStates: Id;
|
||||
receivedAttachments: Id;
|
||||
sentAttachments: Id;
|
||||
spamResults: null | SpamResults;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
SecondFactorAuthService,
|
||||
SessionService,
|
||||
TakeOverDeletedAddressService,
|
||||
VerifierTokenService,
|
||||
} from "../../entities/sys/Services"
|
||||
import { AccountType, asKdfType, CloseEventBusOption, Const, DEFAULT_KDF_TYPE, KdfType } from "../../common/TutanotaConstants"
|
||||
import {
|
||||
|
|
@ -40,6 +41,7 @@ import {
|
|||
createSecondFactorAuthGetData,
|
||||
CreateSessionReturn,
|
||||
createTakeOverDeletedAddressData,
|
||||
createVerifierTokenServiceIn,
|
||||
GroupInfo,
|
||||
GroupInfoTypeRef,
|
||||
RecoverCodeTypeRef,
|
||||
|
|
@ -136,7 +138,14 @@ type ResumeSessionSuccess = { type: "success"; data: ResumeSessionResultData }
|
|||
type ResumeSessionFailure = { type: "error"; reason: ResumeSessionErrorReason }
|
||||
type ResumeSessionResult = ResumeSessionSuccess | ResumeSessionFailure
|
||||
|
||||
type AsyncLoginState = { state: "idle" } | { state: "running" } | { state: "failed"; credentials: Credentials; cacheInfo: CacheInfo }
|
||||
type AsyncLoginState =
|
||||
| { state: "idle" }
|
||||
| { state: "running" }
|
||||
| {
|
||||
state: "failed"
|
||||
credentials: Credentials
|
||||
cacheInfo: CacheInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* All attributes that are required to derive the passphrase key.
|
||||
|
|
@ -759,7 +768,16 @@ export class LoginFacade {
|
|||
*/
|
||||
private async initCache({ userId, databaseKey, timeRangeDays, forceNewDatabase }: InitCacheOptions): Promise<CacheInfo> {
|
||||
if (databaseKey != null) {
|
||||
return { databaseKey, ...(await this.cacheInitializer.initialize({ type: "offline", userId, databaseKey, timeRangeDays, forceNewDatabase })) }
|
||||
return {
|
||||
databaseKey,
|
||||
...(await this.cacheInitializer.initialize({
|
||||
type: "offline",
|
||||
userId,
|
||||
databaseKey,
|
||||
timeRangeDays,
|
||||
forceNewDatabase,
|
||||
})),
|
||||
}
|
||||
} else {
|
||||
return { databaseKey: null, ...(await this.cacheInitializer.initialize({ type: "ephemeral", userId })) }
|
||||
}
|
||||
|
|
@ -809,7 +827,13 @@ export class LoginFacade {
|
|||
}
|
||||
}
|
||||
|
||||
private async loadUserPassphraseKey(mailAddress: string, passphrase: string): Promise<{ kdfType: KdfType; userPassphraseKey: AesKey }> {
|
||||
private async loadUserPassphraseKey(
|
||||
mailAddress: string,
|
||||
passphrase: string,
|
||||
): Promise<{
|
||||
kdfType: KdfType
|
||||
userPassphraseKey: AesKey
|
||||
}> {
|
||||
mailAddress = mailAddress.toLowerCase().trim()
|
||||
const saltRequest = createSaltData({ mailAddress })
|
||||
const saltReturn = await this.serviceExecutor.get(SaltService, saltRequest)
|
||||
|
|
@ -1104,4 +1128,21 @@ export class LoginFacade {
|
|||
throw new Error("credentials went missing")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a verifier token, which is proof of password authentication and is valid for a limited time.
|
||||
* This token will have to be passed back to the server with the appropriate call.
|
||||
*/
|
||||
async getVerifierToken(passphrase: string): Promise<string> {
|
||||
const user = this.userFacade.getLoggedInUser()
|
||||
const passphraseKey = await this.deriveUserPassphraseKey({
|
||||
kdfType: asKdfType(user.kdfVersion),
|
||||
passphrase,
|
||||
salt: assertNotNull(user.salt),
|
||||
})
|
||||
|
||||
const authVerifier = createAuthVerifier(passphraseKey)
|
||||
const out = await this.serviceExecutor.post(VerifierTokenService, createVerifierTokenServiceIn({ authVerifier }))
|
||||
return out.token
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
CacheMode,
|
||||
EntityRestClient,
|
||||
EntityRestClientEraseOptions,
|
||||
EntityRestClientLoadOptions,
|
||||
EntityRestClientSetupOptions,
|
||||
EntityRestInterface,
|
||||
|
|
@ -312,8 +313,8 @@ export class DefaultEntityRestCache implements EntityRestCache {
|
|||
return this.entityRestClient.update(instance)
|
||||
}
|
||||
|
||||
erase<T extends SomeEntity>(instance: T): Promise<void> {
|
||||
return this.entityRestClient.erase(instance)
|
||||
erase<T extends SomeEntity>(instance: T, options?: EntityRestClientEraseOptions): Promise<void> {
|
||||
return this.entityRestClient.erase(instance, options)
|
||||
}
|
||||
|
||||
getLastEntityEventBatchForGroup(groupId: Id): Promise<Id | null> {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ export interface EntityRestClientUpdateOptions {
|
|||
ownerKeyProvider?: OwnerKeyProvider
|
||||
}
|
||||
|
||||
export interface EntityRestClientEraseOptions {
|
||||
extraHeaders?: Dict
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to use the cache to fetch the entity
|
||||
*/
|
||||
|
|
@ -121,7 +125,7 @@ export interface EntityRestInterface {
|
|||
/**
|
||||
* Deletes a single element on the server.
|
||||
*/
|
||||
erase<T extends SomeEntity>(instance: T): Promise<void>
|
||||
erase<T extends SomeEntity>(instance: T, options?: EntityRestClientEraseOptions): Promise<void>
|
||||
|
||||
/**
|
||||
* Must be called when entity events are received.
|
||||
|
|
@ -445,9 +449,16 @@ export class EntityRestClient implements EntityRestInterface {
|
|||
})
|
||||
}
|
||||
|
||||
async erase<T extends SomeEntity>(instance: T): Promise<void> {
|
||||
async erase<T extends SomeEntity>(instance: T, options?: EntityRestClientEraseOptions): Promise<void> {
|
||||
const { listId, elementId } = expandId(instance._id)
|
||||
const { path, queryParams, headers } = await this._validateAndPrepareRestRequest(instance._type, listId, elementId, undefined, undefined, undefined)
|
||||
const { path, queryParams, headers } = await this._validateAndPrepareRestRequest(
|
||||
instance._type,
|
||||
listId,
|
||||
elementId,
|
||||
undefined,
|
||||
options?.extraHeaders,
|
||||
undefined,
|
||||
)
|
||||
await this.restClient.request(path, HttpMethod.DELETE, {
|
||||
queryParams,
|
||||
headers,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { BootIcons } from "../../gui/base/icons/BootIcons.js"
|
|||
|
||||
export function showRequestPasswordDialog(props: {
|
||||
title?: string
|
||||
messageText?: string
|
||||
action: (pw: string) => Promise<string>
|
||||
cancel: {
|
||||
textId: TranslationKey
|
||||
|
|
@ -36,21 +37,24 @@ export function showRequestPasswordDialog(props: {
|
|||
view: () => {
|
||||
const savedState = state
|
||||
return savedState.type == "idle"
|
||||
? m(PasswordField, {
|
||||
label: title,
|
||||
helpLabel: () => savedState.message,
|
||||
value: value,
|
||||
oninput: (newValue) => (value = newValue),
|
||||
autocompleteAs: Autocomplete.off,
|
||||
keyHandler: (key: KeyPress) => {
|
||||
if (isKeyPressed(key.key, Keys.RETURN)) {
|
||||
doAction()
|
||||
return false
|
||||
}
|
||||
? m("", [
|
||||
props.messageText ? m(".pt", props.messageText) : null,
|
||||
m(PasswordField, {
|
||||
label: title,
|
||||
helpLabel: () => savedState.message,
|
||||
value: value,
|
||||
oninput: (newValue) => (value = newValue),
|
||||
autocompleteAs: Autocomplete.off,
|
||||
keyHandler: (key: KeyPress) => {
|
||||
if (isKeyPressed(key.key, Keys.RETURN)) {
|
||||
doAction()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
} satisfies PasswordFieldAttrs)
|
||||
return true
|
||||
},
|
||||
} satisfies PasswordFieldAttrs),
|
||||
])
|
||||
: m(Icon, {
|
||||
icon: BootIcons.Progress,
|
||||
class: "icon-xl icon-progress block mt mb",
|
||||
|
|
|
|||
|
|
@ -51,7 +51,13 @@ export class UserViewer implements UpdatableSettingsDetailsViewer {
|
|||
|
||||
this.mailAddressTableExpanded = false
|
||||
|
||||
this.secondFactorsForm = new SecondFactorsEditForm(this.user, locator.domainConfigProvider())
|
||||
this.secondFactorsForm = new SecondFactorsEditForm(
|
||||
this.user,
|
||||
locator.domainConfigProvider(),
|
||||
locator.loginFacade,
|
||||
this.isAdmin,
|
||||
!!this.userGroupInfo.deleted,
|
||||
)
|
||||
|
||||
this.teamGroupInfos.getAsync().then(async (availableTeamGroupInfos) => {
|
||||
if (availableTeamGroupInfos.length > 0) {
|
||||
|
|
@ -157,10 +163,17 @@ export class UserViewer implements UpdatableSettingsDetailsViewer {
|
|||
}
|
||||
|
||||
private onChangeName(name: string) {
|
||||
Dialog.showProcessTextInputDialog({ title: "edit_action", label: "name_label", defaultValue: name }, (newName) => {
|
||||
this.userGroupInfo.name = newName
|
||||
return locator.entityClient.update(this.userGroupInfo)
|
||||
})
|
||||
Dialog.showProcessTextInputDialog(
|
||||
{
|
||||
title: "edit_action",
|
||||
label: "name_label",
|
||||
defaultValue: name,
|
||||
},
|
||||
(newName) => {
|
||||
this.userGroupInfo.name = newName
|
||||
return locator.entityClient.update(this.userGroupInfo)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private renderAdminStatusSelector(): Children {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ export class LoginSettingsViewer implements UpdatableSettingsViewer {
|
|||
private readonly _secondFactorsForm = new SecondFactorsEditForm(
|
||||
new LazyLoaded(() => Promise.resolve(locator.logins.getUserController().user)),
|
||||
locator.domainConfigProvider(),
|
||||
locator.loginFacade,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
private readonly _usageTestModel: UsageTestModel
|
||||
private credentialEncryptionMode: CredentialEncryptionMode | null = null
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { ButtonSize } from "../../../gui/base/ButtonSize.js"
|
|||
import { NameValidationStatus, SecondFactorEditModel, SecondFactorTypeToNameTextId, VerificationStatus } from "./SecondFactorEditModel.js"
|
||||
import { UserError } from "../../../api/main/UserError.js"
|
||||
import { LoginButton } from "../../../gui/base/buttons/LoginButton.js"
|
||||
import { NotAuthorizedError } from "../../../api/common/error/RestError"
|
||||
|
||||
export class SecondFactorEditDialog {
|
||||
private readonly dialog: Dialog
|
||||
|
|
@ -50,6 +51,12 @@ export class SecondFactorEditDialog {
|
|||
if (e instanceof UserError) {
|
||||
// noinspection ES6MissingAwait
|
||||
Dialog.message(() => e.message)
|
||||
} else if (e instanceof NotAuthorizedError) {
|
||||
this.dialog.close()
|
||||
Dialog.message("contactFormSubmitError_msg")
|
||||
return
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -59,8 +66,8 @@ export class SecondFactorEditDialog {
|
|||
RecoverCodeDialog.showRecoverCodeDialogAfterPasswordVerificationAndInfoDialog(user)
|
||||
}
|
||||
|
||||
static async loadAndShow(entityClient: EntityClient, lazyUser: LazyLoaded<User>, mailAddress: string): Promise<void> {
|
||||
const dialog: SecondFactorEditDialog = await showProgressDialog("pleaseWait_msg", this.loadWebauthnClient(entityClient, lazyUser, mailAddress))
|
||||
static async loadAndShow(entityClient: EntityClient, lazyUser: LazyLoaded<User>, token?: string): Promise<void> {
|
||||
const dialog: SecondFactorEditDialog = await showProgressDialog("pleaseWait_msg", this.loadWebauthnClient(entityClient, lazyUser, token))
|
||||
dialog.dialog.show()
|
||||
}
|
||||
|
||||
|
|
@ -167,22 +174,21 @@ export class SecondFactorEditDialog {
|
|||
}
|
||||
}
|
||||
|
||||
private static async loadWebauthnClient(entityClient: EntityClient, lazyUser: LazyLoaded<User>, mailAddress: string): Promise<SecondFactorEditDialog> {
|
||||
private static async loadWebauthnClient(entityClient: EntityClient, lazyUser: LazyLoaded<User>, token?: string): Promise<SecondFactorEditDialog> {
|
||||
const totpKeys = await locator.loginFacade.generateTotpSecret()
|
||||
const user = await lazyUser.getAsync()
|
||||
const webauthnSupported = await locator.webAuthn.isSupported()
|
||||
const model = new SecondFactorEditModel(
|
||||
entityClient,
|
||||
user,
|
||||
mailAddress,
|
||||
locator.webAuthn,
|
||||
totpKeys,
|
||||
webauthnSupported,
|
||||
lang,
|
||||
locator.loginFacade,
|
||||
location.hostname,
|
||||
locator.domainConfigProvider().getCurrentDomainConfig(),
|
||||
m.redraw,
|
||||
token,
|
||||
)
|
||||
return new SecondFactorEditDialog(model)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,15 +48,14 @@ export class SecondFactorEditModel {
|
|||
constructor(
|
||||
private readonly entityClient: EntityClient,
|
||||
private readonly user: User,
|
||||
private readonly mailAddress: string,
|
||||
private readonly webauthnClient: WebauthnClient,
|
||||
readonly totpKeys: TotpSecret,
|
||||
private readonly webauthnSupported: boolean,
|
||||
private readonly lang: LanguageViewModel,
|
||||
private readonly loginFacade: LoginFacade,
|
||||
private readonly hostname: string,
|
||||
private readonly domainConfig: DomainConfig,
|
||||
private readonly updateViewCallback: () => void,
|
||||
private readonly token?: string,
|
||||
) {
|
||||
this.selectedType = webauthnSupported ? SecondFactorType.webauthn : SecondFactorType.totp
|
||||
this.setDefaultNameIfNeeded()
|
||||
|
|
@ -209,7 +208,7 @@ export class SecondFactorEditModel {
|
|||
sf.otpSecret = this.totpKeys.key
|
||||
}
|
||||
}
|
||||
await this.entityClient.setup(assertNotNull(this.user.auth).secondFactors, sf)
|
||||
await this.entityClient.setup(assertNotNull(this.user.auth).secondFactors, sf, this.token ? { token: this.token } : undefined)
|
||||
return this.user
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,15 +2,13 @@ import m, { Children } from "mithril"
|
|||
import { assertMainOrNode } from "../../../api/common/Env.js"
|
||||
import type { SecondFactor, User } from "../../../api/entities/sys/TypeRefs.js"
|
||||
import { SecondFactorTypeRef } from "../../../api/entities/sys/TypeRefs.js"
|
||||
import { assertNotNull, LazyLoaded, neverNull, ofClass } from "@tutao/tutanota-utils"
|
||||
import { assertNotNull, LazyLoaded, neverNull, noOp } from "@tutao/tutanota-utils"
|
||||
import { Icons } from "../../../gui/base/icons/Icons.js"
|
||||
import { Dialog } from "../../../gui/base/Dialog.js"
|
||||
import { InfoLink, lang } from "../../../misc/LanguageViewModel.js"
|
||||
import { assertEnumValue, SecondFactorType } from "../../../api/common/TutanotaConstants.js"
|
||||
import { showProgressDialog } from "../../../gui/dialogs/ProgressDialog.js"
|
||||
import type { TableAttrs, TableLineAttrs } from "../../../gui/base/Table.js"
|
||||
import { ColumnWidth, Table } from "../../../gui/base/Table.js"
|
||||
import { NotFoundError } from "../../../api/common/error/RestError.js"
|
||||
import { NotAuthorizedError, NotFoundError } from "../../../api/common/error/RestError.js"
|
||||
import { ifAllowedTutaLinks } from "../../../gui/base/GuiUtils.js"
|
||||
import { locator } from "../../../api/main/CommonLocator.js"
|
||||
import { SecondFactorEditDialog } from "./SecondFactorEditDialog.js"
|
||||
|
|
@ -21,16 +19,28 @@ import { appIdToLoginUrl } from "../../../misc/2fa/SecondFactorUtils.js"
|
|||
import { DomainConfigProvider } from "../../../api/common/DomainConfigProvider.js"
|
||||
import { EntityUpdateData, isUpdateForTypeRef } from "../../../api/common/utils/EntityUpdateUtils.js"
|
||||
import { MoreInfoLink } from "../../../misc/news/MoreInfoLink.js"
|
||||
import { showRequestPasswordDialog } from "../../../misc/passwords/PasswordRequestDialog"
|
||||
import { LoginFacade } from "../../../api/worker/facades/LoginFacade"
|
||||
import { showProgressDialog } from "../../../gui/dialogs/ProgressDialog"
|
||||
import { Dialog } from "../../../gui/base/Dialog"
|
||||
|
||||
assertMainOrNode()
|
||||
|
||||
export class SecondFactorsEditForm {
|
||||
_2FALineAttrs: TableLineAttrs[]
|
||||
|
||||
constructor(private readonly user: LazyLoaded<User>, private readonly domainConfigProvider: DomainConfigProvider) {
|
||||
constructor(
|
||||
private readonly user: LazyLoaded<User>,
|
||||
private readonly domainConfigProvider: DomainConfigProvider,
|
||||
private readonly loginFacade: LoginFacade,
|
||||
private askForPassword: boolean,
|
||||
private isDeactivated: boolean,
|
||||
) {
|
||||
this._2FALineAttrs = []
|
||||
|
||||
this._updateSecondFactors()
|
||||
|
||||
this.view = this.view.bind(this)
|
||||
}
|
||||
|
||||
view(): Children {
|
||||
|
|
@ -41,7 +51,15 @@ export class SecondFactorsEditForm {
|
|||
showActionButtonColumn: true,
|
||||
addButtonAttrs: {
|
||||
title: "addSecondFactor_action",
|
||||
click: () => this._showAddSecondFactorDialog(),
|
||||
click: () => {
|
||||
if (this.isDeactivated) {
|
||||
Dialog.message("userAccountDeactivated_msg")
|
||||
} else if (this.askForPassword) {
|
||||
this.showAddSecondFactorDialogWithPasswordCheck()
|
||||
} else {
|
||||
this.showAddSecondFactorDialog()
|
||||
}
|
||||
},
|
||||
icon: Icons.Add,
|
||||
size: ButtonSize.Compact,
|
||||
},
|
||||
|
|
@ -50,7 +68,14 @@ export class SecondFactorsEditForm {
|
|||
m(".h4.mt-l", lang.get("secondFactorAuthentication_label")),
|
||||
m(Table, secondFactorTableAttrs),
|
||||
this.domainConfigProvider.getCurrentDomainConfig().firstPartyDomain
|
||||
? [ifAllowedTutaLinks(locator.logins, InfoLink.SecondFactor, (link) => m(MoreInfoLink, { link: link, isSmall: true }))]
|
||||
? [
|
||||
ifAllowedTutaLinks(locator.logins, InfoLink.SecondFactor, (link) =>
|
||||
m(MoreInfoLink, {
|
||||
link: link,
|
||||
isSmall: true,
|
||||
}),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
]
|
||||
}
|
||||
|
|
@ -73,10 +98,15 @@ export class SecondFactorsEditForm {
|
|||
this._2FALineAttrs = factors.map((f) => {
|
||||
const removeButtonAttrs: IconButtonAttrs = {
|
||||
title: "remove_action",
|
||||
click: () =>
|
||||
Dialog.confirm("confirmDeleteSecondFactor_msg")
|
||||
.then((res) => (res ? showProgressDialog("pleaseWait_msg", locator.entityClient.erase(f)) : Promise.resolve()))
|
||||
.catch(ofClass(NotFoundError, (e) => console.log("could not delete second factor (already deleted)", e))),
|
||||
click: () => {
|
||||
if (this.isDeactivated) {
|
||||
Dialog.message("userAccountDeactivated_msg")
|
||||
} else if (this.askForPassword) {
|
||||
this.removeSecondFactorWithPasswordCheck(f)
|
||||
} else {
|
||||
this.removeSecondFactor(f)
|
||||
}
|
||||
},
|
||||
icon: Icons.Cancel,
|
||||
size: ButtonSize.Compact,
|
||||
}
|
||||
|
|
@ -106,9 +136,72 @@ export class SecondFactorsEditForm {
|
|||
}
|
||||
}
|
||||
|
||||
_showAddSecondFactorDialog() {
|
||||
const mailAddress = assertNotNull(locator.logins.getUserController().userGroupInfo.mailAddress)
|
||||
SecondFactorEditDialog.loadAndShow(locator.entityClient, this.user, mailAddress)
|
||||
private showAddSecondFactorDialogWithPasswordCheck() {
|
||||
const dialog = showRequestPasswordDialog({
|
||||
action: async (passphrase) => {
|
||||
try {
|
||||
const token = await this.loginFacade.getVerifierToken(passphrase)
|
||||
this.showAddSecondFactorDialog(token)
|
||||
} catch (e) {
|
||||
if (e instanceof NotAuthorizedError) {
|
||||
return lang.get("invalidPassword_msg")
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
dialog.close()
|
||||
return ""
|
||||
},
|
||||
cancel: {
|
||||
textId: "cancel_action",
|
||||
action: noOp,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private showAddSecondFactorDialog(token?: string) {
|
||||
SecondFactorEditDialog.loadAndShow(locator.entityClient, this.user, token)
|
||||
}
|
||||
|
||||
private removeSecondFactorWithPasswordCheck(secondFactorToRemove: SecondFactor) {
|
||||
const dialog = showRequestPasswordDialog({
|
||||
action: async (passphrase) => {
|
||||
let token = undefined
|
||||
try {
|
||||
token = await this.loginFacade.getVerifierToken(passphrase)
|
||||
} catch (e) {
|
||||
if (e instanceof NotAuthorizedError) {
|
||||
return lang.get("invalidPassword_msg")
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
this.removeSecondFactor(secondFactorToRemove, token)
|
||||
dialog.close()
|
||||
return ""
|
||||
},
|
||||
messageText: lang.get("confirmDeleteSecondFactor_msg"),
|
||||
cancel: {
|
||||
textId: "cancel_action",
|
||||
action: noOp,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private removeSecondFactor(secondFactorToRemove: SecondFactor, token?: string) {
|
||||
try {
|
||||
let options = undefined
|
||||
if (token) {
|
||||
options = { extraHeaders: { token } }
|
||||
}
|
||||
showProgressDialog("pleaseWait_msg", locator.entityClient.erase(secondFactorToRemove, options))
|
||||
} catch (e) {
|
||||
if (e instanceof NotFoundError) {
|
||||
console.log("could not delete second factor (already deleted)")
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entityEventReceived(update: EntityUpdateData): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { WebauthnClient } from "../../../../../src/common/misc/2fa/webauthn/Weba
|
|||
import { GroupInfoTypeRef, User } from "../../../../../src/common/api/entities/sys/TypeRefs.js"
|
||||
import { TotpSecret, TotpVerifier } from "@tutao/tutanota-crypto"
|
||||
import { noOp } from "@tutao/tutanota-utils"
|
||||
import { LanguageViewModel } from "../../../../../src/common/misc/LanguageViewModel.js"
|
||||
import { LoginFacade } from "../../../../../src/common/api/worker/facades/LoginFacade.js"
|
||||
import { SecondFactorType } from "../../../../../src/common/api/common/TutanotaConstants.js"
|
||||
import { createTestEntity, domainConfigStub } from "../../../TestUtils.js"
|
||||
|
|
@ -29,8 +28,6 @@ o.spec("SecondFactorEditModel", function () {
|
|||
let loginFacadeMock: LoginFacade
|
||||
const totpKeys = createTotpKeys()
|
||||
const validName = "myU2Fkey"
|
||||
const langMock: LanguageViewModel = object()
|
||||
when(langMock.get(matchers.anything())).thenReturn("hello there")
|
||||
// this is too long if you convert it to bytes
|
||||
const invalidName = "🏳️🌈🏳️🌈🏳️🌈🏳️🌈🏴☠️🏴☠️🏴☠️🏴☠️🏴☠️"
|
||||
const hostname = "testhostname"
|
||||
|
|
@ -39,11 +36,9 @@ o.spec("SecondFactorEditModel", function () {
|
|||
const model = new SecondFactorEditModel(
|
||||
params.entityClient ?? entityClientMock,
|
||||
params.user ?? userMock,
|
||||
"testaddress@tutanota.de",
|
||||
params.webAuthnClient ?? webAuthnClientMock,
|
||||
totpKeys,
|
||||
params.webauthnSupported ?? true,
|
||||
langMock,
|
||||
loginFacadeMock,
|
||||
hostname,
|
||||
domainConfigStub,
|
||||
|
|
@ -128,7 +123,7 @@ o.spec("SecondFactorEditModel", function () {
|
|||
o.spec("saving a second factor", function () {
|
||||
o("saving a u2f key, happy path", async function () {
|
||||
const redrawMock = tdfn("redrawMock")
|
||||
when(entityClientMock.setup(matchers.anything(), matchers.anything())).thenResolve("randomID")
|
||||
when(entityClientMock.setup(matchers.anything(), matchers.anything(), matchers.anything())).thenResolve("randomID")
|
||||
when(webAuthnClientMock.register(matchers.anything(), matchers.anything())).thenResolve({})
|
||||
const model = await createSecondFactorModel({ updateView: redrawMock })
|
||||
|
||||
|
|
@ -138,12 +133,12 @@ o.spec("SecondFactorEditModel", function () {
|
|||
o(user).deepEquals(userMock)
|
||||
|
||||
verify(redrawMock(), { times: 2 })
|
||||
verify(entityClientMock.setup(matchers.anything(), matchers.anything()), { times: 1 })
|
||||
verify(entityClientMock.setup(matchers.anything(), matchers.anything(), matchers.anything()), { times: 1 })
|
||||
})
|
||||
|
||||
o("saving a totp key, happy path", async function () {
|
||||
const redrawMock = tdfn("redrawMock")
|
||||
when(entityClientMock.setup(matchers.anything(), matchers.anything())).thenResolve("randomID")
|
||||
when(entityClientMock.setup(matchers.anything(), matchers.anything(), matchers.anything())).thenResolve("randomID")
|
||||
when(webAuthnClientMock.register(matchers.anything(), matchers.anything())).thenResolve({})
|
||||
when(loginFacadeMock.generateTotpCode(matchers.anything(), matchers.anything())).thenResolve(123456)
|
||||
const model = await createSecondFactorModel({ updateView: redrawMock })
|
||||
|
|
@ -155,7 +150,7 @@ o.spec("SecondFactorEditModel", function () {
|
|||
o(user).deepEquals(userMock)
|
||||
|
||||
verify(redrawMock(), { times: 3 })
|
||||
verify(entityClientMock.setup(matchers.anything(), matchers.anything()), { times: 1 })
|
||||
verify(entityClientMock.setup(matchers.anything(), matchers.anything(), matchers.anything()), { times: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -4328,6 +4328,39 @@ impl Entity for VariableExternalAuthInfo {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(test, derive(PartialEq, Debug))]
|
||||
pub struct VerifierTokenServiceIn {
|
||||
pub _format: i64,
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub authVerifier: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Entity for VerifierTokenServiceIn {
|
||||
fn type_ref() -> TypeRef {
|
||||
TypeRef {
|
||||
app: "sys",
|
||||
type_: "VerifierTokenServiceIn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(test, derive(PartialEq, Debug))]
|
||||
pub struct VerifierTokenServiceOut {
|
||||
pub _format: i64,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl Entity for VerifierTokenServiceOut {
|
||||
fn type_ref() -> TypeRef {
|
||||
TypeRef {
|
||||
app: "sys",
|
||||
type_: "VerifierTokenServiceOut",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(uniffi::Record, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(test, derive(PartialEq, Debug))]
|
||||
pub struct VerifyRegistrationCodeData {
|
||||
|
|
|
|||
|
|
@ -1555,7 +1555,9 @@ pub struct MailBox {
|
|||
pub archivedMailBags: Vec<MailBag>,
|
||||
pub currentMailBag: Option<MailBag>,
|
||||
pub folders: Option<MailFolderRef>,
|
||||
pub importedAttachments: GeneratedId,
|
||||
pub mailDetailsDrafts: Option<MailDetailsDraftsRef>,
|
||||
pub mailImportStates: GeneratedId,
|
||||
pub receivedAttachments: GeneratedId,
|
||||
pub sentAttachments: GeneratedId,
|
||||
pub spamResults: Option<SpamResults>,
|
||||
|
|
|
|||
|
|
@ -94,35 +94,37 @@ use crate::entities::generated::sys::UpgradePriceServiceData;
|
|||
use crate::entities::generated::sys::UpgradePriceServiceReturn;
|
||||
use crate::entities::generated::sys::UserGroupKeyRotationPostIn;
|
||||
use crate::entities::generated::sys::UserDataDelete;
|
||||
use crate::entities::generated::sys::VerifierTokenServiceIn;
|
||||
use crate::entities::generated::sys::VerifierTokenServiceOut;
|
||||
use crate::entities::generated::sys::VersionData;
|
||||
use crate::entities::generated::sys::VersionReturn;
|
||||
pub struct AdminGroupKeyRotationService;
|
||||
|
||||
crate::service_impl!(declare, AdminGroupKeyRotationService, "sys/admingroupkeyrotationservice", 115);
|
||||
crate::service_impl!(declare, AdminGroupKeyRotationService, "sys/admingroupkeyrotationservice", 117);
|
||||
crate::service_impl!(POST, AdminGroupKeyRotationService, AdminGroupKeyRotationPostIn, ());
|
||||
|
||||
|
||||
pub struct AffiliatePartnerKpiService;
|
||||
|
||||
crate::service_impl!(declare, AffiliatePartnerKpiService, "sys/affiliatepartnerkpiservice", 115);
|
||||
crate::service_impl!(declare, AffiliatePartnerKpiService, "sys/affiliatepartnerkpiservice", 117);
|
||||
crate::service_impl!(GET, AffiliatePartnerKpiService, (), AffiliatePartnerKpiServiceGetOut);
|
||||
|
||||
|
||||
pub struct AlarmService;
|
||||
|
||||
crate::service_impl!(declare, AlarmService, "sys/alarmservice", 115);
|
||||
crate::service_impl!(declare, AlarmService, "sys/alarmservice", 117);
|
||||
crate::service_impl!(POST, AlarmService, AlarmServicePost, ());
|
||||
|
||||
|
||||
pub struct AppStoreSubscriptionService;
|
||||
|
||||
crate::service_impl!(declare, AppStoreSubscriptionService, "sys/appstoresubscriptionservice", 115);
|
||||
crate::service_impl!(declare, AppStoreSubscriptionService, "sys/appstoresubscriptionservice", 117);
|
||||
crate::service_impl!(GET, AppStoreSubscriptionService, AppStoreSubscriptionGetIn, AppStoreSubscriptionGetOut);
|
||||
|
||||
|
||||
pub struct AutoLoginService;
|
||||
|
||||
crate::service_impl!(declare, AutoLoginService, "sys/autologinservice", 115);
|
||||
crate::service_impl!(declare, AutoLoginService, "sys/autologinservice", 117);
|
||||
crate::service_impl!(POST, AutoLoginService, AutoLoginDataReturn, AutoLoginPostReturn);
|
||||
crate::service_impl!(GET, AutoLoginService, AutoLoginDataGet, AutoLoginDataReturn);
|
||||
crate::service_impl!(DELETE, AutoLoginService, AutoLoginDataDelete, ());
|
||||
|
|
@ -130,7 +132,7 @@ crate::service_impl!(DELETE, AutoLoginService, AutoLoginDataDelete, ());
|
|||
|
||||
pub struct BrandingDomainService;
|
||||
|
||||
crate::service_impl!(declare, BrandingDomainService, "sys/brandingdomainservice", 115);
|
||||
crate::service_impl!(declare, BrandingDomainService, "sys/brandingdomainservice", 117);
|
||||
crate::service_impl!(POST, BrandingDomainService, BrandingDomainData, ());
|
||||
crate::service_impl!(GET, BrandingDomainService, (), BrandingDomainGetReturn);
|
||||
crate::service_impl!(PUT, BrandingDomainService, BrandingDomainData, ());
|
||||
|
|
@ -139,37 +141,37 @@ crate::service_impl!(DELETE, BrandingDomainService, BrandingDomainDeleteData, ()
|
|||
|
||||
pub struct ChangeKdfService;
|
||||
|
||||
crate::service_impl!(declare, ChangeKdfService, "sys/changekdfservice", 115);
|
||||
crate::service_impl!(declare, ChangeKdfService, "sys/changekdfservice", 117);
|
||||
crate::service_impl!(POST, ChangeKdfService, ChangeKdfPostIn, ());
|
||||
|
||||
|
||||
pub struct ChangePasswordService;
|
||||
|
||||
crate::service_impl!(declare, ChangePasswordService, "sys/changepasswordservice", 115);
|
||||
crate::service_impl!(declare, ChangePasswordService, "sys/changepasswordservice", 117);
|
||||
crate::service_impl!(POST, ChangePasswordService, ChangePasswordPostIn, ());
|
||||
|
||||
|
||||
pub struct CloseSessionService;
|
||||
|
||||
crate::service_impl!(declare, CloseSessionService, "sys/closesessionservice", 115);
|
||||
crate::service_impl!(declare, CloseSessionService, "sys/closesessionservice", 117);
|
||||
crate::service_impl!(POST, CloseSessionService, CloseSessionServicePost, ());
|
||||
|
||||
|
||||
pub struct CreateCustomerServerProperties;
|
||||
|
||||
crate::service_impl!(declare, CreateCustomerServerProperties, "sys/createcustomerserverproperties", 115);
|
||||
crate::service_impl!(declare, CreateCustomerServerProperties, "sys/createcustomerserverproperties", 117);
|
||||
crate::service_impl!(POST, CreateCustomerServerProperties, CreateCustomerServerPropertiesData, CreateCustomerServerPropertiesReturn);
|
||||
|
||||
|
||||
pub struct CustomDomainCheckService;
|
||||
|
||||
crate::service_impl!(declare, CustomDomainCheckService, "sys/customdomaincheckservice", 115);
|
||||
crate::service_impl!(declare, CustomDomainCheckService, "sys/customdomaincheckservice", 117);
|
||||
crate::service_impl!(GET, CustomDomainCheckService, CustomDomainCheckGetIn, CustomDomainCheckGetOut);
|
||||
|
||||
|
||||
pub struct CustomDomainService;
|
||||
|
||||
crate::service_impl!(declare, CustomDomainService, "sys/customdomainservice", 115);
|
||||
crate::service_impl!(declare, CustomDomainService, "sys/customdomainservice", 117);
|
||||
crate::service_impl!(POST, CustomDomainService, CustomDomainData, CustomDomainReturn);
|
||||
crate::service_impl!(PUT, CustomDomainService, CustomDomainData, ());
|
||||
crate::service_impl!(DELETE, CustomDomainService, CustomDomainData, ());
|
||||
|
|
@ -177,50 +179,50 @@ crate::service_impl!(DELETE, CustomDomainService, CustomDomainData, ());
|
|||
|
||||
pub struct CustomerAccountTerminationService;
|
||||
|
||||
crate::service_impl!(declare, CustomerAccountTerminationService, "sys/customeraccountterminationservice", 115);
|
||||
crate::service_impl!(declare, CustomerAccountTerminationService, "sys/customeraccountterminationservice", 117);
|
||||
crate::service_impl!(POST, CustomerAccountTerminationService, CustomerAccountTerminationPostIn, CustomerAccountTerminationPostOut);
|
||||
|
||||
|
||||
pub struct CustomerPublicKeyService;
|
||||
|
||||
crate::service_impl!(declare, CustomerPublicKeyService, "sys/customerpublickeyservice", 115);
|
||||
crate::service_impl!(declare, CustomerPublicKeyService, "sys/customerpublickeyservice", 117);
|
||||
crate::service_impl!(GET, CustomerPublicKeyService, (), PublicKeyGetOut);
|
||||
|
||||
|
||||
pub struct CustomerService;
|
||||
|
||||
crate::service_impl!(declare, CustomerService, "sys/customerservice", 115);
|
||||
crate::service_impl!(declare, CustomerService, "sys/customerservice", 117);
|
||||
crate::service_impl!(DELETE, CustomerService, DeleteCustomerData, ());
|
||||
|
||||
|
||||
pub struct DebitService;
|
||||
|
||||
crate::service_impl!(declare, DebitService, "sys/debitservice", 115);
|
||||
crate::service_impl!(declare, DebitService, "sys/debitservice", 117);
|
||||
crate::service_impl!(PUT, DebitService, DebitServicePutData, ());
|
||||
|
||||
|
||||
pub struct DomainMailAddressAvailabilityService;
|
||||
|
||||
crate::service_impl!(declare, DomainMailAddressAvailabilityService, "sys/domainmailaddressavailabilityservice", 115);
|
||||
crate::service_impl!(declare, DomainMailAddressAvailabilityService, "sys/domainmailaddressavailabilityservice", 117);
|
||||
crate::service_impl!(GET, DomainMailAddressAvailabilityService, DomainMailAddressAvailabilityData, DomainMailAddressAvailabilityReturn);
|
||||
|
||||
|
||||
pub struct ExternalPropertiesService;
|
||||
|
||||
crate::service_impl!(declare, ExternalPropertiesService, "sys/externalpropertiesservice", 115);
|
||||
crate::service_impl!(declare, ExternalPropertiesService, "sys/externalpropertiesservice", 117);
|
||||
crate::service_impl!(GET, ExternalPropertiesService, (), ExternalPropertiesReturn);
|
||||
|
||||
|
||||
pub struct GiftCardRedeemService;
|
||||
|
||||
crate::service_impl!(declare, GiftCardRedeemService, "sys/giftcardredeemservice", 115);
|
||||
crate::service_impl!(declare, GiftCardRedeemService, "sys/giftcardredeemservice", 117);
|
||||
crate::service_impl!(POST, GiftCardRedeemService, GiftCardRedeemData, ());
|
||||
crate::service_impl!(GET, GiftCardRedeemService, GiftCardRedeemData, GiftCardRedeemGetReturn);
|
||||
|
||||
|
||||
pub struct GiftCardService;
|
||||
|
||||
crate::service_impl!(declare, GiftCardService, "sys/giftcardservice", 115);
|
||||
crate::service_impl!(declare, GiftCardService, "sys/giftcardservice", 117);
|
||||
crate::service_impl!(POST, GiftCardService, GiftCardCreateData, GiftCardCreateReturn);
|
||||
crate::service_impl!(GET, GiftCardService, (), GiftCardGetReturn);
|
||||
crate::service_impl!(DELETE, GiftCardService, GiftCardDeleteData, ());
|
||||
|
|
@ -228,37 +230,37 @@ crate::service_impl!(DELETE, GiftCardService, GiftCardDeleteData, ());
|
|||
|
||||
pub struct GroupKeyRotationInfoService;
|
||||
|
||||
crate::service_impl!(declare, GroupKeyRotationInfoService, "sys/groupkeyrotationinfoservice", 115);
|
||||
crate::service_impl!(declare, GroupKeyRotationInfoService, "sys/groupkeyrotationinfoservice", 117);
|
||||
crate::service_impl!(GET, GroupKeyRotationInfoService, (), GroupKeyRotationInfoGetOut);
|
||||
|
||||
|
||||
pub struct GroupKeyRotationService;
|
||||
|
||||
crate::service_impl!(declare, GroupKeyRotationService, "sys/groupkeyrotationservice", 115);
|
||||
crate::service_impl!(declare, GroupKeyRotationService, "sys/groupkeyrotationservice", 117);
|
||||
crate::service_impl!(POST, GroupKeyRotationService, GroupKeyRotationPostIn, ());
|
||||
|
||||
|
||||
pub struct InvoiceDataService;
|
||||
|
||||
crate::service_impl!(declare, InvoiceDataService, "sys/invoicedataservice", 115);
|
||||
crate::service_impl!(declare, InvoiceDataService, "sys/invoicedataservice", 117);
|
||||
crate::service_impl!(GET, InvoiceDataService, InvoiceDataGetIn, InvoiceDataGetOut);
|
||||
|
||||
|
||||
pub struct LocalAdminRemovalService;
|
||||
|
||||
crate::service_impl!(declare, LocalAdminRemovalService, "sys/localadminremovalservice", 115);
|
||||
crate::service_impl!(declare, LocalAdminRemovalService, "sys/localadminremovalservice", 117);
|
||||
crate::service_impl!(POST, LocalAdminRemovalService, LocalAdminRemovalPostIn, ());
|
||||
|
||||
|
||||
pub struct LocationService;
|
||||
|
||||
crate::service_impl!(declare, LocationService, "sys/locationservice", 115);
|
||||
crate::service_impl!(declare, LocationService, "sys/locationservice", 117);
|
||||
crate::service_impl!(GET, LocationService, (), LocationServiceGetReturn);
|
||||
|
||||
|
||||
pub struct MailAddressAliasService;
|
||||
|
||||
crate::service_impl!(declare, MailAddressAliasService, "sys/mailaddressaliasservice", 115);
|
||||
crate::service_impl!(declare, MailAddressAliasService, "sys/mailaddressaliasservice", 117);
|
||||
crate::service_impl!(POST, MailAddressAliasService, MailAddressAliasServiceData, ());
|
||||
crate::service_impl!(GET, MailAddressAliasService, MailAddressAliasGetIn, MailAddressAliasServiceReturn);
|
||||
crate::service_impl!(DELETE, MailAddressAliasService, MailAddressAliasServiceDataDelete, ());
|
||||
|
|
@ -266,7 +268,7 @@ crate::service_impl!(DELETE, MailAddressAliasService, MailAddressAliasServiceDat
|
|||
|
||||
pub struct MembershipService;
|
||||
|
||||
crate::service_impl!(declare, MembershipService, "sys/membershipservice", 115);
|
||||
crate::service_impl!(declare, MembershipService, "sys/membershipservice", 117);
|
||||
crate::service_impl!(POST, MembershipService, MembershipAddData, ());
|
||||
crate::service_impl!(PUT, MembershipService, MembershipPutIn, ());
|
||||
crate::service_impl!(DELETE, MembershipService, MembershipRemoveData, ());
|
||||
|
|
@ -274,13 +276,13 @@ crate::service_impl!(DELETE, MembershipService, MembershipRemoveData, ());
|
|||
|
||||
pub struct MultipleMailAddressAvailabilityService;
|
||||
|
||||
crate::service_impl!(declare, MultipleMailAddressAvailabilityService, "sys/multiplemailaddressavailabilityservice", 115);
|
||||
crate::service_impl!(declare, MultipleMailAddressAvailabilityService, "sys/multiplemailaddressavailabilityservice", 117);
|
||||
crate::service_impl!(GET, MultipleMailAddressAvailabilityService, MultipleMailAddressAvailabilityData, MultipleMailAddressAvailabilityReturn);
|
||||
|
||||
|
||||
pub struct PaymentDataService;
|
||||
|
||||
crate::service_impl!(declare, PaymentDataService, "sys/paymentdataservice", 115);
|
||||
crate::service_impl!(declare, PaymentDataService, "sys/paymentdataservice", 117);
|
||||
crate::service_impl!(POST, PaymentDataService, PaymentDataServicePostData, ());
|
||||
crate::service_impl!(GET, PaymentDataService, PaymentDataServiceGetData, PaymentDataServiceGetReturn);
|
||||
crate::service_impl!(PUT, PaymentDataService, PaymentDataServicePutData, PaymentDataServicePutReturn);
|
||||
|
|
@ -288,71 +290,71 @@ crate::service_impl!(PUT, PaymentDataService, PaymentDataServicePutData, Payment
|
|||
|
||||
pub struct PlanService;
|
||||
|
||||
crate::service_impl!(declare, PlanService, "sys/planservice", 115);
|
||||
crate::service_impl!(declare, PlanService, "sys/planservice", 117);
|
||||
crate::service_impl!(GET, PlanService, (), PlanServiceGetOut);
|
||||
|
||||
|
||||
pub struct PriceService;
|
||||
|
||||
crate::service_impl!(declare, PriceService, "sys/priceservice", 115);
|
||||
crate::service_impl!(declare, PriceService, "sys/priceservice", 117);
|
||||
crate::service_impl!(GET, PriceService, PriceServiceData, PriceServiceReturn);
|
||||
|
||||
|
||||
pub struct PublicKeyService;
|
||||
|
||||
crate::service_impl!(declare, PublicKeyService, "sys/publickeyservice", 115);
|
||||
crate::service_impl!(declare, PublicKeyService, "sys/publickeyservice", 117);
|
||||
crate::service_impl!(GET, PublicKeyService, PublicKeyGetIn, PublicKeyGetOut);
|
||||
crate::service_impl!(PUT, PublicKeyService, PublicKeyPutIn, ());
|
||||
|
||||
|
||||
pub struct ReferralCodeService;
|
||||
|
||||
crate::service_impl!(declare, ReferralCodeService, "sys/referralcodeservice", 115);
|
||||
crate::service_impl!(declare, ReferralCodeService, "sys/referralcodeservice", 117);
|
||||
crate::service_impl!(POST, ReferralCodeService, ReferralCodePostIn, ReferralCodePostOut);
|
||||
crate::service_impl!(GET, ReferralCodeService, ReferralCodeGetIn, ());
|
||||
|
||||
|
||||
pub struct RegistrationCaptchaService;
|
||||
|
||||
crate::service_impl!(declare, RegistrationCaptchaService, "sys/registrationcaptchaservice", 115);
|
||||
crate::service_impl!(declare, RegistrationCaptchaService, "sys/registrationcaptchaservice", 117);
|
||||
crate::service_impl!(POST, RegistrationCaptchaService, RegistrationCaptchaServiceData, ());
|
||||
crate::service_impl!(GET, RegistrationCaptchaService, RegistrationCaptchaServiceGetData, RegistrationCaptchaServiceReturn);
|
||||
|
||||
|
||||
pub struct RegistrationService;
|
||||
|
||||
crate::service_impl!(declare, RegistrationService, "sys/registrationservice", 115);
|
||||
crate::service_impl!(declare, RegistrationService, "sys/registrationservice", 117);
|
||||
crate::service_impl!(POST, RegistrationService, RegistrationServiceData, RegistrationReturn);
|
||||
crate::service_impl!(GET, RegistrationService, (), RegistrationServiceData);
|
||||
|
||||
|
||||
pub struct ResetFactorsService;
|
||||
|
||||
crate::service_impl!(declare, ResetFactorsService, "sys/resetfactorsservice", 115);
|
||||
crate::service_impl!(declare, ResetFactorsService, "sys/resetfactorsservice", 117);
|
||||
crate::service_impl!(DELETE, ResetFactorsService, ResetFactorsDeleteData, ());
|
||||
|
||||
|
||||
pub struct ResetPasswordService;
|
||||
|
||||
crate::service_impl!(declare, ResetPasswordService, "sys/resetpasswordservice", 115);
|
||||
crate::service_impl!(declare, ResetPasswordService, "sys/resetpasswordservice", 117);
|
||||
crate::service_impl!(POST, ResetPasswordService, ResetPasswordPostIn, ());
|
||||
|
||||
|
||||
pub struct SaltService;
|
||||
|
||||
crate::service_impl!(declare, SaltService, "sys/saltservice", 115);
|
||||
crate::service_impl!(declare, SaltService, "sys/saltservice", 117);
|
||||
crate::service_impl!(GET, SaltService, SaltData, SaltReturn);
|
||||
|
||||
|
||||
pub struct SecondFactorAuthAllowedService;
|
||||
|
||||
crate::service_impl!(declare, SecondFactorAuthAllowedService, "sys/secondfactorauthallowedservice", 115);
|
||||
crate::service_impl!(declare, SecondFactorAuthAllowedService, "sys/secondfactorauthallowedservice", 117);
|
||||
crate::service_impl!(GET, SecondFactorAuthAllowedService, (), SecondFactorAuthAllowedReturn);
|
||||
|
||||
|
||||
pub struct SecondFactorAuthService;
|
||||
|
||||
crate::service_impl!(declare, SecondFactorAuthService, "sys/secondfactorauthservice", 115);
|
||||
crate::service_impl!(declare, SecondFactorAuthService, "sys/secondfactorauthservice", 117);
|
||||
crate::service_impl!(POST, SecondFactorAuthService, SecondFactorAuthData, ());
|
||||
crate::service_impl!(GET, SecondFactorAuthService, SecondFactorAuthGetData, SecondFactorAuthGetReturn);
|
||||
crate::service_impl!(DELETE, SecondFactorAuthService, SecondFactorAuthDeleteData, ());
|
||||
|
|
@ -360,65 +362,71 @@ crate::service_impl!(DELETE, SecondFactorAuthService, SecondFactorAuthDeleteData
|
|||
|
||||
pub struct SessionService;
|
||||
|
||||
crate::service_impl!(declare, SessionService, "sys/sessionservice", 115);
|
||||
crate::service_impl!(declare, SessionService, "sys/sessionservice", 117);
|
||||
crate::service_impl!(POST, SessionService, CreateSessionData, CreateSessionReturn);
|
||||
|
||||
|
||||
pub struct SignOrderProcessingAgreementService;
|
||||
|
||||
crate::service_impl!(declare, SignOrderProcessingAgreementService, "sys/signorderprocessingagreementservice", 115);
|
||||
crate::service_impl!(declare, SignOrderProcessingAgreementService, "sys/signorderprocessingagreementservice", 117);
|
||||
crate::service_impl!(POST, SignOrderProcessingAgreementService, SignOrderProcessingAgreementData, ());
|
||||
|
||||
|
||||
pub struct SwitchAccountTypeService;
|
||||
|
||||
crate::service_impl!(declare, SwitchAccountTypeService, "sys/switchaccounttypeservice", 115);
|
||||
crate::service_impl!(declare, SwitchAccountTypeService, "sys/switchaccounttypeservice", 117);
|
||||
crate::service_impl!(POST, SwitchAccountTypeService, SwitchAccountTypePostIn, ());
|
||||
|
||||
|
||||
pub struct SystemKeysService;
|
||||
|
||||
crate::service_impl!(declare, SystemKeysService, "sys/systemkeysservice", 115);
|
||||
crate::service_impl!(declare, SystemKeysService, "sys/systemkeysservice", 117);
|
||||
crate::service_impl!(GET, SystemKeysService, (), SystemKeysReturn);
|
||||
|
||||
|
||||
pub struct TakeOverDeletedAddressService;
|
||||
|
||||
crate::service_impl!(declare, TakeOverDeletedAddressService, "sys/takeoverdeletedaddressservice", 115);
|
||||
crate::service_impl!(declare, TakeOverDeletedAddressService, "sys/takeoverdeletedaddressservice", 117);
|
||||
crate::service_impl!(POST, TakeOverDeletedAddressService, TakeOverDeletedAddressData, ());
|
||||
|
||||
|
||||
pub struct UpdatePermissionKeyService;
|
||||
|
||||
crate::service_impl!(declare, UpdatePermissionKeyService, "sys/updatepermissionkeyservice", 115);
|
||||
crate::service_impl!(declare, UpdatePermissionKeyService, "sys/updatepermissionkeyservice", 117);
|
||||
crate::service_impl!(POST, UpdatePermissionKeyService, UpdatePermissionKeyData, ());
|
||||
|
||||
|
||||
pub struct UpdateSessionKeysService;
|
||||
|
||||
crate::service_impl!(declare, UpdateSessionKeysService, "sys/updatesessionkeysservice", 115);
|
||||
crate::service_impl!(declare, UpdateSessionKeysService, "sys/updatesessionkeysservice", 117);
|
||||
crate::service_impl!(POST, UpdateSessionKeysService, UpdateSessionKeysPostIn, ());
|
||||
|
||||
|
||||
pub struct UpgradePriceService;
|
||||
|
||||
crate::service_impl!(declare, UpgradePriceService, "sys/upgradepriceservice", 115);
|
||||
crate::service_impl!(declare, UpgradePriceService, "sys/upgradepriceservice", 117);
|
||||
crate::service_impl!(GET, UpgradePriceService, UpgradePriceServiceData, UpgradePriceServiceReturn);
|
||||
|
||||
|
||||
pub struct UserGroupKeyRotationService;
|
||||
|
||||
crate::service_impl!(declare, UserGroupKeyRotationService, "sys/usergroupkeyrotationservice", 115);
|
||||
crate::service_impl!(declare, UserGroupKeyRotationService, "sys/usergroupkeyrotationservice", 117);
|
||||
crate::service_impl!(POST, UserGroupKeyRotationService, UserGroupKeyRotationPostIn, ());
|
||||
|
||||
|
||||
pub struct UserService;
|
||||
|
||||
crate::service_impl!(declare, UserService, "sys/userservice", 115);
|
||||
crate::service_impl!(declare, UserService, "sys/userservice", 117);
|
||||
crate::service_impl!(DELETE, UserService, UserDataDelete, ());
|
||||
|
||||
|
||||
pub struct VerifierTokenService;
|
||||
|
||||
crate::service_impl!(declare, VerifierTokenService, "sys/verifiertokenservice", 117);
|
||||
crate::service_impl!(POST, VerifierTokenService, VerifierTokenServiceIn, VerifierTokenServiceOut);
|
||||
|
||||
|
||||
pub struct VersionService;
|
||||
|
||||
crate::service_impl!(declare, VersionService, "sys/versionservice", 115);
|
||||
crate::service_impl!(declare, VersionService, "sys/versionservice", 117);
|
||||
crate::service_impl!(GET, VersionService, VersionData, VersionReturn);
|
||||
|
|
|
|||
|
|
@ -46,58 +46,58 @@ use crate::entities::generated::tutanota::UnreadMailStatePostIn;
|
|||
use crate::entities::generated::tutanota::UserAccountCreateData;
|
||||
pub struct ApplyLabelService;
|
||||
|
||||
crate::service_impl!(declare, ApplyLabelService, "tutanota/applylabelservice", 78);
|
||||
crate::service_impl!(declare, ApplyLabelService, "tutanota/applylabelservice", 79);
|
||||
crate::service_impl!(POST, ApplyLabelService, ApplyLabelServicePostIn, ());
|
||||
|
||||
|
||||
pub struct CalendarService;
|
||||
|
||||
crate::service_impl!(declare, CalendarService, "tutanota/calendarservice", 78);
|
||||
crate::service_impl!(declare, CalendarService, "tutanota/calendarservice", 79);
|
||||
crate::service_impl!(POST, CalendarService, UserAreaGroupPostData, CreateGroupPostReturn);
|
||||
crate::service_impl!(DELETE, CalendarService, CalendarDeleteData, ());
|
||||
|
||||
|
||||
pub struct ContactListGroupService;
|
||||
|
||||
crate::service_impl!(declare, ContactListGroupService, "tutanota/contactlistgroupservice", 78);
|
||||
crate::service_impl!(declare, ContactListGroupService, "tutanota/contactlistgroupservice", 79);
|
||||
crate::service_impl!(POST, ContactListGroupService, UserAreaGroupPostData, CreateGroupPostReturn);
|
||||
crate::service_impl!(DELETE, ContactListGroupService, UserAreaGroupDeleteData, ());
|
||||
|
||||
|
||||
pub struct CustomerAccountService;
|
||||
|
||||
crate::service_impl!(declare, CustomerAccountService, "tutanota/customeraccountservice", 78);
|
||||
crate::service_impl!(declare, CustomerAccountService, "tutanota/customeraccountservice", 79);
|
||||
crate::service_impl!(POST, CustomerAccountService, CustomerAccountCreateData, ());
|
||||
|
||||
|
||||
pub struct DraftService;
|
||||
|
||||
crate::service_impl!(declare, DraftService, "tutanota/draftservice", 78);
|
||||
crate::service_impl!(declare, DraftService, "tutanota/draftservice", 79);
|
||||
crate::service_impl!(POST, DraftService, DraftCreateData, DraftCreateReturn);
|
||||
crate::service_impl!(PUT, DraftService, DraftUpdateData, DraftUpdateReturn);
|
||||
|
||||
|
||||
pub struct EncryptTutanotaPropertiesService;
|
||||
|
||||
crate::service_impl!(declare, EncryptTutanotaPropertiesService, "tutanota/encrypttutanotapropertiesservice", 78);
|
||||
crate::service_impl!(declare, EncryptTutanotaPropertiesService, "tutanota/encrypttutanotapropertiesservice", 79);
|
||||
crate::service_impl!(POST, EncryptTutanotaPropertiesService, EncryptTutanotaPropertiesData, ());
|
||||
|
||||
|
||||
pub struct EntropyService;
|
||||
|
||||
crate::service_impl!(declare, EntropyService, "tutanota/entropyservice", 78);
|
||||
crate::service_impl!(declare, EntropyService, "tutanota/entropyservice", 79);
|
||||
crate::service_impl!(PUT, EntropyService, EntropyData, ());
|
||||
|
||||
|
||||
pub struct ExternalUserService;
|
||||
|
||||
crate::service_impl!(declare, ExternalUserService, "tutanota/externaluserservice", 78);
|
||||
crate::service_impl!(declare, ExternalUserService, "tutanota/externaluserservice", 79);
|
||||
crate::service_impl!(POST, ExternalUserService, ExternalUserData, ());
|
||||
|
||||
|
||||
pub struct GroupInvitationService;
|
||||
|
||||
crate::service_impl!(declare, GroupInvitationService, "tutanota/groupinvitationservice", 78);
|
||||
crate::service_impl!(declare, GroupInvitationService, "tutanota/groupinvitationservice", 79);
|
||||
crate::service_impl!(POST, GroupInvitationService, GroupInvitationPostData, GroupInvitationPostReturn);
|
||||
crate::service_impl!(PUT, GroupInvitationService, GroupInvitationPutData, ());
|
||||
crate::service_impl!(DELETE, GroupInvitationService, GroupInvitationDeleteData, ());
|
||||
|
|
@ -105,13 +105,13 @@ crate::service_impl!(DELETE, GroupInvitationService, GroupInvitationDeleteData,
|
|||
|
||||
pub struct ListUnsubscribeService;
|
||||
|
||||
crate::service_impl!(declare, ListUnsubscribeService, "tutanota/listunsubscribeservice", 78);
|
||||
crate::service_impl!(declare, ListUnsubscribeService, "tutanota/listunsubscribeservice", 79);
|
||||
crate::service_impl!(POST, ListUnsubscribeService, ListUnsubscribeData, ());
|
||||
|
||||
|
||||
pub struct MailFolderService;
|
||||
|
||||
crate::service_impl!(declare, MailFolderService, "tutanota/mailfolderservice", 78);
|
||||
crate::service_impl!(declare, MailFolderService, "tutanota/mailfolderservice", 79);
|
||||
crate::service_impl!(POST, MailFolderService, CreateMailFolderData, CreateMailFolderReturn);
|
||||
crate::service_impl!(PUT, MailFolderService, UpdateMailFolderData, ());
|
||||
crate::service_impl!(DELETE, MailFolderService, DeleteMailFolderData, ());
|
||||
|
|
@ -119,81 +119,81 @@ crate::service_impl!(DELETE, MailFolderService, DeleteMailFolderData, ());
|
|||
|
||||
pub struct MailGroupService;
|
||||
|
||||
crate::service_impl!(declare, MailGroupService, "tutanota/mailgroupservice", 78);
|
||||
crate::service_impl!(declare, MailGroupService, "tutanota/mailgroupservice", 79);
|
||||
crate::service_impl!(POST, MailGroupService, CreateMailGroupData, ());
|
||||
crate::service_impl!(DELETE, MailGroupService, DeleteGroupData, ());
|
||||
|
||||
|
||||
pub struct MailService;
|
||||
|
||||
crate::service_impl!(declare, MailService, "tutanota/mailservice", 78);
|
||||
crate::service_impl!(declare, MailService, "tutanota/mailservice", 79);
|
||||
crate::service_impl!(DELETE, MailService, DeleteMailData, ());
|
||||
|
||||
|
||||
pub struct ManageLabelService;
|
||||
|
||||
crate::service_impl!(declare, ManageLabelService, "tutanota/managelabelservice", 78);
|
||||
crate::service_impl!(declare, ManageLabelService, "tutanota/managelabelservice", 79);
|
||||
crate::service_impl!(POST, ManageLabelService, ManageLabelServicePostIn, ());
|
||||
crate::service_impl!(DELETE, ManageLabelService, ManageLabelServiceDeleteIn, ());
|
||||
|
||||
|
||||
pub struct MoveMailService;
|
||||
|
||||
crate::service_impl!(declare, MoveMailService, "tutanota/movemailservice", 78);
|
||||
crate::service_impl!(declare, MoveMailService, "tutanota/movemailservice", 79);
|
||||
crate::service_impl!(POST, MoveMailService, MoveMailData, ());
|
||||
|
||||
|
||||
pub struct NewsService;
|
||||
|
||||
crate::service_impl!(declare, NewsService, "tutanota/newsservice", 78);
|
||||
crate::service_impl!(declare, NewsService, "tutanota/newsservice", 79);
|
||||
crate::service_impl!(POST, NewsService, NewsIn, ());
|
||||
crate::service_impl!(GET, NewsService, (), NewsOut);
|
||||
|
||||
|
||||
pub struct ReceiveInfoService;
|
||||
|
||||
crate::service_impl!(declare, ReceiveInfoService, "tutanota/receiveinfoservice", 78);
|
||||
crate::service_impl!(declare, ReceiveInfoService, "tutanota/receiveinfoservice", 79);
|
||||
crate::service_impl!(POST, ReceiveInfoService, ReceiveInfoServiceData, ());
|
||||
|
||||
|
||||
pub struct ReportMailService;
|
||||
|
||||
crate::service_impl!(declare, ReportMailService, "tutanota/reportmailservice", 78);
|
||||
crate::service_impl!(declare, ReportMailService, "tutanota/reportmailservice", 79);
|
||||
crate::service_impl!(POST, ReportMailService, ReportMailPostData, ());
|
||||
|
||||
|
||||
pub struct SendDraftService;
|
||||
|
||||
crate::service_impl!(declare, SendDraftService, "tutanota/senddraftservice", 78);
|
||||
crate::service_impl!(declare, SendDraftService, "tutanota/senddraftservice", 79);
|
||||
crate::service_impl!(POST, SendDraftService, SendDraftData, SendDraftReturn);
|
||||
|
||||
|
||||
pub struct SimpleMoveMailService;
|
||||
|
||||
crate::service_impl!(declare, SimpleMoveMailService, "tutanota/simplemovemailservice", 78);
|
||||
crate::service_impl!(declare, SimpleMoveMailService, "tutanota/simplemovemailservice", 79);
|
||||
crate::service_impl!(POST, SimpleMoveMailService, SimpleMoveMailPostIn, ());
|
||||
|
||||
|
||||
pub struct TemplateGroupService;
|
||||
|
||||
crate::service_impl!(declare, TemplateGroupService, "tutanota/templategroupservice", 78);
|
||||
crate::service_impl!(declare, TemplateGroupService, "tutanota/templategroupservice", 79);
|
||||
crate::service_impl!(POST, TemplateGroupService, UserAreaGroupPostData, CreateGroupPostReturn);
|
||||
crate::service_impl!(DELETE, TemplateGroupService, UserAreaGroupDeleteData, ());
|
||||
|
||||
|
||||
pub struct TranslationService;
|
||||
|
||||
crate::service_impl!(declare, TranslationService, "tutanota/translationservice", 78);
|
||||
crate::service_impl!(declare, TranslationService, "tutanota/translationservice", 79);
|
||||
crate::service_impl!(GET, TranslationService, TranslationGetIn, TranslationGetOut);
|
||||
|
||||
|
||||
pub struct UnreadMailStateService;
|
||||
|
||||
crate::service_impl!(declare, UnreadMailStateService, "tutanota/unreadmailstateservice", 78);
|
||||
crate::service_impl!(declare, UnreadMailStateService, "tutanota/unreadmailstateservice", 79);
|
||||
crate::service_impl!(POST, UnreadMailStateService, UnreadMailStatePostIn, ());
|
||||
|
||||
|
||||
pub struct UserAccountService;
|
||||
|
||||
crate::service_impl!(declare, UserAccountService, "tutanota/useraccountservice", 78);
|
||||
crate::service_impl!(declare, UserAccountService, "tutanota/useraccountservice", 79);
|
||||
crate::service_impl!(POST, UserAccountService, UserAccountCreateData, ());
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue