diff options
| author | tamaina <tamaina@hotmail.co.jp> | 2023-02-20 16:40:24 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-02-20 16:40:24 +0900 |
| commit | 980bf1306e2d097782958f024a86391fc28278a0 (patch) | |
| tree | e1190b5fa0b8b18a425dee0dcdbf580ce2235c5f /packages/frontend/src | |
| parent | refactor: 型エラー修正 / Fix type errors backend (#9983) (diff) | |
| download | sharkey-980bf1306e2d097782958f024a86391fc28278a0.tar.gz sharkey-980bf1306e2d097782958f024a86391fc28278a0.tar.bz2 sharkey-980bf1306e2d097782958f024a86391fc28278a0.zip | |
:art: 2FA設定のデザイン向上 / セキュリティキーの名前を変更できるように (#9985)
* wip
* fix
* wip
* wip
* :v:
* rename key
* :art:
* update CHANGELOG.md
* パスワードレスログインの判断はサーバーで
* 日本語
* 日本語
* 日本語
* 日本語
* :v:
* fix
* refactor
* トークン→確認コード
* fix password-less / qr click
* use otpauth
* 日本語
* autocomplete
* パスワードレス設定は外に出す
* :art:
* :art:
---------
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
Diffstat (limited to 'packages/frontend/src')
| -rw-r--r-- | packages/frontend/src/components/MkDialog.vue | 39 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkFolder.vue | 35 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkInput.vue | 4 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkSelect.vue | 4 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkSignin.vue | 6 | ||||
| -rw-r--r-- | packages/frontend/src/os.ts | 8 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/2fa.qrdialog.vue | 82 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/2fa.vue | 366 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/security.vue | 12 |
9 files changed, 371 insertions, 185 deletions
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 9690353432..863ea702cd 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -14,8 +14,12 @@ </div> <header v-if="title" :class="$style.title"><Mfm :text="title"/></header> <div v-if="text" :class="$style.text"><Mfm :text="text"/></div> - <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown"> + <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown"> <template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template> + <template #caption> + <span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })" /> + <span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })" /> + </template> </MkInput> <MkSelect v-if="select" v-model="selectedValue" autofocus> <template v-if="select.items"> @@ -28,7 +32,7 @@ </template> </MkSelect> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> - <MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> + <MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> <MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> </div> <div v-if="actions" :class="$style.buttons"> @@ -47,9 +51,12 @@ import MkSelect from '@/components/MkSelect.vue'; import { i18n } from '@/i18n'; type Input = { - type: HTMLInputElement['type']; + type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; placeholder?: string | null; - default: any | null; + autocomplete?: string; + default: string | number | null; + minLength?: number; + maxLength?: number; }; type Select = { @@ -98,8 +105,28 @@ const emit = defineEmits<{ const modal = shallowRef<InstanceType<typeof MkModal>>(); -const inputValue = ref(props.input?.default || null); -const selectedValue = ref(props.select?.default || null); +const inputValue = ref<string | number | null>(props.input?.default ?? null); +const selectedValue = ref(props.select?.default ?? null); + +let disabledReason = $ref<null | 'charactersExceeded' | 'charactersBelow'>(null); +const okButtonDisabled = $computed<boolean>(() => { + if (props.input) { + if (props.input.minLength) { + if ((inputValue.value || inputValue.value === '') && (inputValue.value as string).length < props.input.minLength) { + disabledReason = 'charactersBelow'; + return true; + } + } + if (props.input.maxLength) { + if (inputValue.value && (inputValue.value as string).length > props.input.maxLength) { + disabledReason = 'charactersExceeded'; + return true; + } + } + } + + return false; +}); function done(canceled: boolean, result?) { emit('done', { canceled, result }); diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index a1d7210d7e..b97e36cd5f 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -1,13 +1,20 @@ <template> <div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]"> <div :class="$style.header" class="_button" @click="toggle"> - <span :class="$style.headerIcon"><slot name="icon"></slot></span> - <span :class="$style.headerText"><slot name="label"></slot></span> - <span :class="$style.headerRight"> + <div :class="$style.headerIcon"><slot name="icon"></slot></div> + <div :class="$style.headerText"> + <div :class="$style.headerTextMain"> + <slot name="label"></slot> + </div> + <div :class="$style.headerTextSub"> + <slot name="caption"></slot> + </div> + </div> + <div :class="$style.headerRight"> <span :class="$style.headerRightText"><slot name="suffix"></slot></span> <i v-if="opened" class="ti ti-chevron-up icon"></i> <i v-else class="ti ti-chevron-down icon"></i> - </span> + </div> </div> <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }"> <Transition @@ -139,6 +146,17 @@ onMounted(() => { } } +.headerUpper { + display: flex; + align-items: center; +} + +.headerLower { + color: var(--fgTransparentWeak); + font-size: .85em; + padding-left: 4px; +} + .headerIcon { margin-right: 0.75em; flex-shrink: 0; @@ -161,6 +179,15 @@ onMounted(() => { padding-right: 12px; } +.headerTextMain { + +} + +.headerTextSub { + color: var(--fgTransparentWeak); + font-size: .85em; +} + .headerRight { margin-left: auto; opacity: 0.7; diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index da6177c2f9..0f99bf9aad 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -41,7 +41,7 @@ import { useInterval } from '@/scripts/use-interval'; import { i18n } from '@/i18n'; const props = defineProps<{ - modelValue: string | number; + modelValue: string | number | null; type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; required?: boolean; readonly?: boolean; @@ -49,7 +49,7 @@ const props = defineProps<{ pattern?: string; placeholder?: string; autofocus?: boolean; - autocomplete?: boolean; + autocomplete?: string; spellcheck?: boolean; step?: any; datalist?: string[]; diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index cb64b1e484..2de890186a 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -34,7 +34,7 @@ import { useInterval } from '@/scripts/use-interval'; import { i18n } from '@/i18n'; const props = defineProps<{ - modelValue: string; + modelValue: string | null; required?: boolean; readonly?: boolean; disabled?: boolean; @@ -48,7 +48,7 @@ const props = defineProps<{ const emit = defineEmits<{ (ev: 'change', _ev: KeyboardEvent): void; - (ev: 'update:modelValue', value: string): void; + (ev: 'update:modelValue', value: string | null): void; }>(); const slots = useSlots(); diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index ae4f38e56c..ffc5e82b56 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -10,7 +10,7 @@ <template #prefix>@</template> <template #suffix>@{{ host }}</template> </MkInput> - <MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" :with-password-toggle="true" required data-cy-signin-password> + <MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password" :with-password-toggle="true" required data-cy-signin-password> <template #prefix><i class="ti ti-lock"></i></template> <template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template> </MkInput> @@ -28,11 +28,11 @@ </div> <div class="twofa-group totp-group"> <p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p> - <MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" :with-password-toggle="true" required> + <MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :with-password-toggle="true" required> <template #label>{{ i18n.ts.password }}</template> <template #prefix><i class="ti ti-lock"></i></template> </MkInput> - <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false" required> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="one-time-code" :spellcheck="false" required> <template #label>{{ i18n.ts.token }}</template> <template #prefix><i class="ti ti-123"></i></template> </MkInput> diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 6bff12661f..a69fe73f30 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -246,7 +246,10 @@ export function inputText(props: { title?: string | null; text?: string | null; placeholder?: string | null; + autocomplete?: string; default?: string | null; + minLength?: number; + maxLength?: number; }): Promise<{ canceled: true; result: undefined; } | { canceled: false; result: string; }> { @@ -257,7 +260,10 @@ export function inputText(props: { input: { type: props.type, placeholder: props.placeholder, + autocomplete: props.autocomplete, default: props.default, + minLength: props.minLength, + maxLength: props.maxLength, }, }, { done: result => { @@ -271,6 +277,7 @@ export function inputNumber(props: { title?: string | null; text?: string | null; placeholder?: string | null; + autocomplete?: string; default?: number | null; }): Promise<{ canceled: true; result: undefined; } | { canceled: false; result: number; @@ -282,6 +289,7 @@ export function inputNumber(props: { input: { type: 'number', placeholder: props.placeholder, + autocomplete: props.autocomplete, default: props.default, }, }, { diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue new file mode 100644 index 0000000000..1d836db5f5 --- /dev/null +++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue @@ -0,0 +1,82 @@ +<template> +<MkModal + ref="dialogEl" + :prefer-type="'dialog'" + :z-priority="'low'" + @click="cancel" + @close="cancel" + @closed="emit('closed')" +> + <div :class="$style.root" class="_gaps_m"> + <I18n :src="i18n.ts._2fa.step1" tag="div"> + <template #a> + <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a> + </template> + <template #b> + <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a> + </template> + </I18n> + <div> + {{ i18n.ts._2fa.step2 }}<br> + {{ i18n.ts._2fa.step2Click }} + </div> + <a :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a> + <MkKeyValue :copy="twoFactorData.url"> + <template #key>{{ i18n.ts._2fa.step2Url }}</template> + <template #value>{{ twoFactorData.url }}</template> + </MkKeyValue> + <div class="_buttons"> + <MkButton primary @click="ok">{{ i18n.ts.next }}</MkButton> + <MkButton @click="cancel">{{ i18n.ts.cancel }}</MkButton> + </div> + </div> +</MkModal> +</template> + +<script lang="ts" setup> +import MkButton from '@/components/MkButton.vue'; +import MkModal from '@/components/MkModal.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import { i18n } from '@/i18n'; + +defineProps<{ + twoFactorData: { + qr: string; + url: string; + }; +}>(); + +const emit = defineEmits<{ + (ev: 'ok'): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); + +const cancel = () => { + emit('cancel'); + emit('closed'); +}; + +const ok = () => { + emit('ok'); + emit('closed'); +}; +</script> + +<style lang="scss" module> +.root { + position: relative; + margin: auto; + padding: 32px; + min-width: 320px; + max-width: calc(100svw - 64px); + box-sizing: border-box; + background: var(--panel); + border-radius: var(--radius); +} + +.qr { + width: 20em; + max-width: 100%; +} +</style> diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index e6ef09668c..891934d706 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -1,216 +1,258 @@ <template> -<div> - <MkButton v-if="!twoFactorData && !$i.twoFactorEnabled" @click="register">{{ i18n.ts._2fa.registerDevice }}</MkButton> - <template v-if="$i.twoFactorEnabled"> - <p>{{ i18n.ts._2fa.alreadyRegistered }}</p> - <MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton> +<FormSection :first="first"> + <template #label>{{ i18n.ts['2fa'] }}</template> - <template v-if="supportsCredentials"> - <hr class="totp-method-sep"> - - <h2 class="heading">{{ i18n.ts.securityKey }}</h2> - <p>{{ i18n.ts._2fa.securityKeyInfo }}</p> - <div class="key-list"> - <div v-for="key in $i.securityKeysList" class="key"> - <h3>{{ key.name }}</h3> - <div class="last-used">{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed"/></div> - <MkButton @click="unregisterKey(key)">{{ i18n.ts.unregister }}</MkButton> - </div> + <div v-if="$i" class="_gaps_s"> + <MkFolder> + <template #icon><i class="ti ti-shield-lock"></i></template> + <template #label>{{ i18n.ts.totp }}</template> + <template #caption>{{ i18n.ts.totpDescription }}</template> + <div v-if="$i.twoFactorEnabled" class="_gaps_s"> + <div v-text="i18n.ts._2fa.alreadyRegistered"/> + <template v-if="$i.securityKeysList.length > 0"> + <MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton> + <MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo> + </template> + <MkButton v-else @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton> </div> - <MkSwitch v-if="$i.securityKeysList.length > 0" v-model="usePasswordLessLogin" @update:model-value="updatePasswordLessLogin">{{ i18n.ts.passwordLessLogin }}</MkSwitch> + <MkButton v-else-if="!twoFactorData && !$i.twoFactorEnabled" @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton> + </MkFolder> + + <MkFolder> + <template #icon><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.securityKeyAndPasskey }}</template> + <div class="_gaps_s"> + <MkInfo> + {{ i18n.ts._2fa.securityKeyInfo }}<br> + <br> + {{ i18n.ts._2fa.chromePasskeyNotSupported }} + </MkInfo> - <MkInfo v-if="registration && registration.error" warn>{{ i18n.ts.error }} {{ registration.error }}</MkInfo> - <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ i18n.ts._2fa.registerKey }}</MkButton> + <MkInfo v-if="!supportsCredentials" warn> + {{ i18n.ts._2fa.securityKeyNotSupported }} + </MkInfo> - <ol v-if="registration && !registration.error"> - <li v-if="registration.stage >= 0"> - {{ i18n.ts.tapSecurityKey }} - <MkLoading v-if="registration.saving && registration.stage == 0" :em="true"/> - </li> - <li v-if="registration.stage >= 1"> - <MkForm :disabled="registration.stage != 1 || registration.saving"> - <MkInput v-model="keyName" :max="30"> - <template #label>{{ i18n.ts.securityKeyName }}</template> - </MkInput> - <MkButton :disabled="keyName.length == 0" @click="registerKey">{{ i18n.ts.registerSecurityKey }}</MkButton> - <MkLoading v-if="registration.saving && registration.stage == 1" :em="true"/> - </MkForm> - </li> - </ol> - </template> - </template> - <div v-if="twoFactorData && !$i.twoFactorEnabled"> - <ol style="margin: 0; padding: 0 0 0 1em;"> - <li> - <I18n :src="i18n.ts._2fa.step1" tag="span"> - <template #a> - <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a> - </template> - <template #b> - <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a> - </template> - </I18n> - </li> - <li>{{ i18n.ts._2fa.step2 }}<br><img :src="twoFactorData.qr"><p>{{ $ts._2fa.step2Url }}<br>{{ twoFactorData.url }}</p></li> - <li> - {{ i18n.ts._2fa.step3 }}<br> - <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput> - <MkButton primary @click="submit">{{ i18n.ts.done }}</MkButton> - </li> - </ol> - <MkInfo>{{ i18n.ts._2fa.step4 }}</MkInfo> + <MkInfo v-else-if="supportsCredentials && !$i.twoFactorEnabled" warn> + {{ i18n.ts._2fa.registerTOTPBeforeKey }} + </MkInfo> + + <template v-else> + <MkButton primary @click="addSecurityKey">{{ i18n.ts._2fa.registerSecurityKey }}</MkButton> + <MkFolder v-for="key in $i.securityKeysList" :key="key.id"> + <template #label>{{ key.name }}</template> + <template #suffix><I18n :src="i18n.ts.lastUsedAt"><template #t><MkTime :time="key.lastUsed"/></template></I18n></template> + <div class="_buttons"> + <MkButton @click="renameKey(key)"><i class="ti ti-forms"></i> {{ i18n.ts.rename }}</MkButton> + <MkButton danger @click="unregisterKey(key)"><i class="ti ti-trash"></i> {{ i18n.ts.unregister }}</MkButton> + </div> + </MkFolder> + </template> + </div> + </MkFolder> + + <MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList.length === 0" :model-value="usePasswordLessLogin" @update:model-value="v => updatePasswordLessLogin(v)"> + <template #label>{{ i18n.ts.passwordLessLogin }}</template> + <template #caption>{{ i18n.ts.passwordLessLoginDescription }}</template> + </MkSwitch> </div> -</div> +</FormSection> </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, defineAsyncComponent } from 'vue'; import { hostname } from '@/config'; import { byteify, hexify, stringify } from '@/scripts/2fa'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; -import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; +import FormSection from '@/components/form/section.vue'; +import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os'; import { $i } from '@/account'; import { i18n } from '@/i18n'; +// メモ: 各エンドポイントはmeUpdatedを発行するため、refreshAccountは不要 + +withDefaults(defineProps<{ + first?: boolean; +}>(), { + first: false, +}); + const twoFactorData = ref<any>(null); const supportsCredentials = ref(!!navigator.credentials); -const usePasswordLessLogin = ref($i!.usePasswordLessLogin); -const registration = ref<any>(null); -const keyName = ref(''); -const token = ref(null); +const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin); -function register() { - os.inputText({ - title: i18n.ts.password, +async function registerTOTP() { + const password = await os.inputText({ + title: i18n.ts._2fa.registerTOTP, + text: i18n.ts._2fa.passwordToTOTP, type: 'password', - }).then(({ canceled, result: password }) => { - if (canceled) return; - os.api('i/2fa/register', { - password: password, - }).then(data => { - twoFactorData.value = data; - }); + autocomplete: 'current-password', + }); + if (password.canceled) return; + + const twoFactorData = await os.apiWithDialog('i/2fa/register', { + password: password.result, + }); + + const qrdialog = await new Promise<boolean>(res => { + os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), { + twoFactorData, + }, { + 'ok': () => res(true), + 'cancel': () => res(false), + }, 'closed'); + }); + if (!qrdialog) return; + + const token = await os.inputNumber({ + title: i18n.ts._2fa.step3Title, + text: i18n.ts._2fa.step3, + autocomplete: 'one-time-code', + }); + if (token.canceled) return; + + await os.apiWithDialog('i/2fa/done', { + token: token.result.toString(), + }); + + await os.alert({ + type: 'success', + text: i18n.ts._2fa.step4, }); } -function unregister() { +function unregisterTOTP() { os.inputText({ title: i18n.ts.password, type: 'password', + autocomplete: 'current-password', }).then(({ canceled, result: password }) => { if (canceled) return; - os.api('i/2fa/unregister', { + os.apiWithDialog('i/2fa/unregister', { password: password, - }).then(() => { - usePasswordLessLogin.value = false; - updatePasswordLessLogin(); - }).then(() => { - os.success(); - $i!.twoFactorEnabled = false; + }).catch(error => { + os.alert({ + type: 'error', + text: error, + }); }); }); } -function submit() { - os.api('i/2fa/done', { - token: token.value, - }).then(() => { - os.success(); - $i!.twoFactorEnabled = true; - }).catch(err => { - os.alert({ - type: 'error', - text: err, - }); +function renewTOTP() { + os.confirm({ + type: 'question', + title: i18n.ts._2fa.renewTOTP, + text: i18n.ts._2fa.renewTOTPConfirm, + okText: i18n.ts._2fa.renewTOTPOk, + cancelText: i18n.ts._2fa.renewTOTPCancel, + }).then(({ canceled }) => { + if (canceled) return; + registerTOTP(); }); } -function registerKey() { - registration.value.saving = true; - os.api('i/2fa/key-done', { - password: registration.value.password, - name: keyName.value, - challengeId: registration.value.challengeId, - // we convert each 16 bits to a string to serialise - clientDataJSON: stringify(registration.value.credential.response.clientDataJSON), - attestationObject: hexify(registration.value.credential.response.attestationObject), - }).then(key => { - registration.value = null; - key.lastUsed = new Date(); - os.success(); +async function unregisterKey(key) { + const confirm = await os.confirm({ + type: 'question', + title: i18n.ts._2fa.removeKey, + text: i18n.t('_2fa.removeKeyConfirm', { name: key.name }), }); -} + if (confirm.canceled) return; -function unregisterKey(key) { - os.inputText({ + const password = await os.inputText({ title: i18n.ts.password, type: 'password', - }).then(({ canceled, result: password }) => { - if (canceled) return; - return os.api('i/2fa/remove-key', { - password, - credentialId: key.id, - }).then(() => { - usePasswordLessLogin.value = false; - updatePasswordLessLogin(); - }).then(() => { - os.success(); - }); + autocomplete: 'current-password', + }); + if (password.canceled) return; + + await os.apiWithDialog('i/2fa/remove-key', { + password: password.result, + credentialId: key.id, }); + os.success(); } -function addSecurityKey() { - os.inputText({ +async function renameKey(key) { + const name = await os.inputText({ + title: i18n.ts.rename, + default: key.name, + type: 'text', + minLength: 1, + maxLength: 30, + }); + if (name.canceled) return; + + await os.apiWithDialog('i/2fa/update-key', { + name: name.result, + credentialId: key.id, + }); +} + +async function addSecurityKey() { + const password = await os.inputText({ title: i18n.ts.password, type: 'password', - }).then(({ canceled, result: password }) => { - if (canceled) return; - os.api('i/2fa/register-key', { - password, - }).then(reg => { - registration.value = { - password, - challengeId: reg!.challengeId, - stage: 0, - publicKeyOptions: { - challenge: byteify(reg!.challenge, 'base64'), - rp: { - id: hostname, - name: 'Misskey', - }, - user: { - id: byteify($i!.id, 'ascii'), - name: $i!.username, - displayName: $i!.name, - }, - pubKeyCredParams: [{ alg: -7, type: 'public-key' }], - timeout: 60000, - attestation: 'direct', - }, - saving: true, - }; - return navigator.credentials.create({ - publicKey: registration.value.publicKeyOptions, - }); - }).then(credential => { - registration.value.credential = credential; - registration.value.saving = false; - registration.value.stage = 1; - }).catch(err => { - console.warn('Error while registering?', err); - registration.value.error = err.message; - registration.value.stage = -1; - }); + autocomplete: 'current-password', + }); + if (password.canceled) return; + + const challenge: any = await os.apiWithDialog('i/2fa/register-key', { + password: password.result, + }); + + const name = await os.inputText({ + title: i18n.ts._2fa.registerSecurityKey, + text: i18n.ts._2fa.securityKeyName, + type: 'text', + minLength: 1, + maxLength: 30, + }); + if (name.canceled) return; + + const webAuthnCreation = navigator.credentials.create({ + publicKey: { + challenge: byteify(challenge.challenge, 'base64'), + rp: { + id: hostname, + name: 'Misskey', + }, + user: { + id: byteify($i!.id, 'ascii'), + name: $i!.username, + displayName: $i!.name, + }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + timeout: 60000, + attestation: 'direct', + }, + }) as Promise<PublicKeyCredential & { response: AuthenticatorAttestationResponse; } | null>; + + const credential = await os.promiseDialog( + webAuthnCreation, + null, + () => {}, // ユーザーのキャンセルはrejectなのでエラーダイアログを出さない + i18n.ts._2fa.tapSecurityKey, + ); + if (!credential) return; + + await os.apiWithDialog('i/2fa/key-done', { + password: password.result, + name: name.result, + challengeId: challenge.challengeId, + // we convert each 16 bits to a string to serialise + clientDataJSON: stringify(credential.response.clientDataJSON), + attestationObject: hexify(credential.response.attestationObject), }); } -async function updatePasswordLessLogin() { - await os.api('i/2fa/password-less', { - value: !!usePasswordLessLogin.value, +async function updatePasswordLessLogin(value: boolean) { + await os.apiWithDialog('i/2fa/password-less', { + value, }); } </script> diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue index b09c4ffd2f..0cc2df09c5 100644 --- a/packages/frontend/src/pages/settings/security.vue +++ b/packages/frontend/src/pages/settings/security.vue @@ -5,11 +5,8 @@ <MkButton primary @click="change()">{{ i18n.ts.changePassword }}</MkButton> </FormSection> - <FormSection> - <template #label>{{ i18n.ts.twoStepAuthentication }}</template> - <X2fa/> - </FormSection> - + <X2fa/> + <FormSection> <template #label>{{ i18n.ts.signinHistory }}</template> <MkPagination :pagination="pagination" disable-auto-load> @@ -56,18 +53,21 @@ async function change() { const { canceled: canceled1, result: currentPassword } = await os.inputText({ title: i18n.ts.currentPassword, type: 'password', + autocomplete: 'current-password', }); if (canceled1) return; const { canceled: canceled2, result: newPassword } = await os.inputText({ title: i18n.ts.newPassword, type: 'password', + autocomplete: 'new-password', }); if (canceled2) return; const { canceled: canceled3, result: newPassword2 } = await os.inputText({ title: i18n.ts.newPasswordRetype, type: 'password', + autocomplete: 'new-password', }); if (canceled3) return; @@ -109,7 +109,7 @@ definePageMetadata({ <style lang="scss" scoped> .timnmucd { - padding: 16px; + padding: 12px; &:first-child { border-top-left-radius: 6px; |