summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authortamaina <tamaina@hotmail.co.jp>2023-02-20 16:40:24 +0900
committerGitHub <noreply@github.com>2023-02-20 16:40:24 +0900
commit980bf1306e2d097782958f024a86391fc28278a0 (patch)
treee1190b5fa0b8b18a425dee0dcdbf580ce2235c5f /packages/frontend/src
parentrefactor: 型エラー修正 / Fix type errors backend (#9983) (diff)
downloadsharkey-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.vue39
-rw-r--r--packages/frontend/src/components/MkFolder.vue35
-rw-r--r--packages/frontend/src/components/MkInput.vue4
-rw-r--r--packages/frontend/src/components/MkSelect.vue4
-rw-r--r--packages/frontend/src/components/MkSignin.vue6
-rw-r--r--packages/frontend/src/os.ts8
-rw-r--r--packages/frontend/src/pages/settings/2fa.qrdialog.vue82
-rw-r--r--packages/frontend/src/pages/settings/2fa.vue366
-rw-r--r--packages/frontend/src/pages/settings/security.vue12
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;