enhance(frontend): パスワード変更時にHIBPで流出パスワードをチェックするように (MisskeyIO#625)
This commit is contained in:
parent
86e15db338
commit
5f8d4cf7b4
4
locales/index.d.ts
vendored
4
locales/index.d.ts
vendored
@ -7904,10 +7904,6 @@ export interface Locale extends ILocale {
|
|||||||
* バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。
|
* バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。
|
||||||
*/
|
*/
|
||||||
"backupCodesExhaustedWarning": string;
|
"backupCodesExhaustedWarning": string;
|
||||||
/**
|
|
||||||
* 詳細なガイドはこちら
|
|
||||||
*/
|
|
||||||
"moreDetailedGuideHere": string;
|
|
||||||
/**
|
/**
|
||||||
* バックアップコードを保存しましたか?
|
* バックアップコードを保存しましたか?
|
||||||
*/
|
*/
|
||||||
|
161
packages/frontend/src/components/MkNewPassword.vue
Normal file
161
packages/frontend/src/components/MkNewPassword.vue
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<MkInput v-model="password" :debounce="true" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword">
|
||||||
|
<template #label>
|
||||||
|
{{ label }} <a href="https://haveibeenpwned.com/Passwords" target="_blank" rel="nofollow noopener"><span :class="$style.hibpLogo">leak checked by <span>';--hibp?</span></span></a>
|
||||||
|
</template>
|
||||||
|
<template #prefix><i class="ti ti-lock"></i></template>
|
||||||
|
<template #caption>
|
||||||
|
<span v-if="passwordStrength == 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
|
||||||
|
<span v-if="passwordStrength == 'low' && isLeaked" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.tsx.leakedPassword({ n: leakedCount }) }}</span>
|
||||||
|
<span v-else-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span>
|
||||||
|
<span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span>
|
||||||
|
<span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
|
||||||
|
</template>
|
||||||
|
</MkInput>
|
||||||
|
<MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:modelValue="onChangePasswordRetype">
|
||||||
|
<template #label>{{ label }} ({{ i18n.ts.retype }})</template>
|
||||||
|
<template #prefix><i class="ti ti-lock"></i></template>
|
||||||
|
<template #caption>
|
||||||
|
<span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span>
|
||||||
|
<span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span>
|
||||||
|
</template>
|
||||||
|
</MkInput>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import MkInput from '@/components/MkInput.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
label: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const password = ref<string>('');
|
||||||
|
const retypedPassword = ref<string>('');
|
||||||
|
const passwordStrength = ref<'' | 'wait' | 'low' | 'medium' | 'high'>('');
|
||||||
|
const isLeaked = ref(false);
|
||||||
|
const leakedCount = ref(0);
|
||||||
|
const passwordRetypeState = ref<null | 'match' | 'not-match'>(null);
|
||||||
|
const passwordAbortController = ref<null | AbortController>(null);
|
||||||
|
|
||||||
|
const isValid = computed((): boolean => {
|
||||||
|
return passwordRetypeState.value === 'match'
|
||||||
|
&& (passwordStrength.value === 'medium' || passwordStrength.value === 'high');
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
isValid,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getPasswordStrength(source: string): Promise<number> {
|
||||||
|
let strength = 0;
|
||||||
|
let power = 0.018;
|
||||||
|
|
||||||
|
// 英数字
|
||||||
|
if (/[a-zA-Z]/.test(source) && /[0-9]/.test(source)) {
|
||||||
|
power += 0.020;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大文字と小文字が混ざってたら
|
||||||
|
if (/[a-z]/.test(source) && /[A-Z]/.test(source)) {
|
||||||
|
power += 0.015;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 記号が混ざってたら
|
||||||
|
if (/[!\x22\#$%&@'()*+,-./_]/.test(source)) {
|
||||||
|
power += 0.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
strength = power * source.length;
|
||||||
|
|
||||||
|
// check HIBP 3 chars or more
|
||||||
|
if (passwordAbortController.value != null) {
|
||||||
|
passwordAbortController.value.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.length >= 3) {
|
||||||
|
passwordStrength.value = 'wait';
|
||||||
|
passwordAbortController.value = new AbortController();
|
||||||
|
|
||||||
|
const hash = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(source));
|
||||||
|
const hashHex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
const hashPrefix = hashHex.slice(0, 5).toUpperCase();
|
||||||
|
const hashSuffix = hashHex.slice(5).toUpperCase();
|
||||||
|
await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`, { signal: passwordAbortController.value.signal })
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then(text => {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const line = lines.find(l => l.startsWith(hashSuffix));
|
||||||
|
if (line) {
|
||||||
|
leakedCount.value = parseInt(line.split(':')[1]);
|
||||||
|
isLeaked.value = true;
|
||||||
|
strength = 0;
|
||||||
|
} else {
|
||||||
|
isLeaked.value = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
leakedCount.value = 0;
|
||||||
|
isLeaked.value = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
leakedCount.value = 0;
|
||||||
|
isLeaked.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(1, strength));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onChangePassword(): Promise<void> {
|
||||||
|
if (password.value === '') {
|
||||||
|
passwordStrength.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strength = await getPasswordStrength(password.value);
|
||||||
|
passwordStrength.value = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
|
||||||
|
|
||||||
|
onChangePasswordRetype();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangePasswordRetype(): void {
|
||||||
|
if (retypedPassword.value === '') {
|
||||||
|
passwordRetypeState.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordRetypeState.value = password.value === retypedPassword.value ? 'match' : 'not-match';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.hibpLogo {
|
||||||
|
background: linear-gradient(45deg, #616c70, #626262);
|
||||||
|
color: #fefefe;
|
||||||
|
display: inline-flex;
|
||||||
|
padding-left: 8px;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
align-items: center;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
|
||||||
|
span {
|
||||||
|
background: linear-gradient(45deg, #255e81, #338cac);
|
||||||
|
font-size: 1.4em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
height: auto;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -45,27 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
|
<span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
|
||||||
</template>
|
</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<MkInput v-model="password" :debounce="true" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword">
|
<MkNewPassword ref="password" :label="i18n.ts.password"/>
|
||||||
<template #label>
|
|
||||||
{{ i18n.ts.password }} <a href="https://haveibeenpwned.com/Passwords" target="_blank" rel="nofollow noopener"><span :class="$style.hibpLogo">leak checked by <span>';--hibp?</span></span></a>
|
|
||||||
</template>
|
|
||||||
<template #prefix><i class="ti ti-lock"></i></template>
|
|
||||||
<template #caption>
|
|
||||||
<span v-if="passwordStrength == 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
|
|
||||||
<span v-if="passwordStrength == 'low' && isLeaked" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.tsx.leakedPassword({ n: leakedCount }) }}</span>
|
|
||||||
<span v-else-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span>
|
|
||||||
<span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span>
|
|
||||||
<span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
|
|
||||||
</template>
|
|
||||||
</MkInput>
|
|
||||||
<MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:modelValue="onChangePasswordRetype">
|
|
||||||
<template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template>
|
|
||||||
<template #prefix><i class="ti ti-lock"></i></template>
|
|
||||||
<template #caption>
|
|
||||||
<span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span>
|
|
||||||
<span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span>
|
|
||||||
</template>
|
|
||||||
</MkInput>
|
|
||||||
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
|
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
|
||||||
<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
|
<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
|
||||||
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
|
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
|
||||||
@ -82,11 +62,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, shallowRef } from 'vue';
|
||||||
import { toUnicode } from 'punycode/';
|
import { toUnicode } from 'punycode/';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import MkButton from './MkButton.vue';
|
import MkButton from './MkButton.vue';
|
||||||
import MkInput from './MkInput.vue';
|
import MkInput from './MkInput.vue';
|
||||||
|
import MkNewPassword from '@/components/MkNewPassword.vue';
|
||||||
import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';
|
import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';
|
||||||
import * as config from '@/config.js';
|
import * as config from '@/config.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
@ -113,16 +94,11 @@ const recaptcha = ref<Captcha | undefined>();
|
|||||||
const turnstile = ref<Captcha | undefined>();
|
const turnstile = ref<Captcha | undefined>();
|
||||||
|
|
||||||
const username = ref<string>('');
|
const username = ref<string>('');
|
||||||
const password = ref<string>('');
|
const password = shallowRef<InstanceType<typeof MkNewPassword> | null>(null);
|
||||||
const retypedPassword = ref<string>('');
|
|
||||||
const invitationCode = ref<string>('');
|
const invitationCode = ref<string>('');
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const usernameState = ref<null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range'>(null);
|
const usernameState = ref<null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range'>(null);
|
||||||
const emailState = ref<null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:banned' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error'>(null);
|
const emailState = ref<null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:banned' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error'>(null);
|
||||||
const passwordStrength = ref<'' | 'wait' | 'low' | 'medium' | 'high'>('');
|
|
||||||
const isLeaked = ref(false);
|
|
||||||
const leakedCount = ref(0);
|
|
||||||
const passwordRetypeState = ref<null | 'match' | 'not-match'>(null);
|
|
||||||
const submitting = ref<boolean>(false);
|
const submitting = ref<boolean>(false);
|
||||||
const hCaptchaResponse = ref<string | null>(null);
|
const hCaptchaResponse = ref<string | null>(null);
|
||||||
const mCaptchaResponse = ref<string | null>(null);
|
const mCaptchaResponse = ref<string | null>(null);
|
||||||
@ -130,7 +106,6 @@ const reCaptchaResponse = ref<string | null>(null);
|
|||||||
const turnstileResponse = ref<string | null>(null);
|
const turnstileResponse = ref<string | null>(null);
|
||||||
const usernameAbortController = ref<null | AbortController>(null);
|
const usernameAbortController = ref<null | AbortController>(null);
|
||||||
const emailAbortController = ref<null | AbortController>(null);
|
const emailAbortController = ref<null | AbortController>(null);
|
||||||
const passwordAbortController = ref<null | AbortController>(null);
|
|
||||||
|
|
||||||
const shouldDisableSubmitting = computed((): boolean => {
|
const shouldDisableSubmitting = computed((): boolean => {
|
||||||
return submitting.value ||
|
return submitting.value ||
|
||||||
@ -139,75 +114,10 @@ const shouldDisableSubmitting = computed((): boolean => {
|
|||||||
instance.enableRecaptcha && !reCaptchaResponse.value ||
|
instance.enableRecaptcha && !reCaptchaResponse.value ||
|
||||||
instance.enableTurnstile && !turnstileResponse.value ||
|
instance.enableTurnstile && !turnstileResponse.value ||
|
||||||
instance.emailRequiredForSignup && emailState.value !== 'ok' ||
|
instance.emailRequiredForSignup && emailState.value !== 'ok' ||
|
||||||
(passwordStrength.value !== 'medium' && passwordStrength.value !== 'high') ||
|
|
||||||
usernameState.value !== 'ok' ||
|
usernameState.value !== 'ok' ||
|
||||||
passwordRetypeState.value !== 'match';
|
!password.value?.isValid;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function getPasswordStrength(source: string): Promise<number> {
|
|
||||||
let strength = 0;
|
|
||||||
let power = 0.018;
|
|
||||||
|
|
||||||
// 英数字
|
|
||||||
if (/[a-zA-Z]/.test(source) && /[0-9]/.test(source)) {
|
|
||||||
power += 0.020;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 大文字と小文字が混ざってたら
|
|
||||||
if (/[a-z]/.test(source) && /[A-Z]/.test(source)) {
|
|
||||||
power += 0.015;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 記号が混ざってたら
|
|
||||||
if (/[!\x22\#$%&@'()*+,-./_]/.test(source)) {
|
|
||||||
power += 0.02;
|
|
||||||
}
|
|
||||||
|
|
||||||
strength = power * source.length;
|
|
||||||
|
|
||||||
// check HIBP 3 chars or more
|
|
||||||
if (passwordAbortController.value != null) {
|
|
||||||
passwordAbortController.value.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source.length >= 3) {
|
|
||||||
passwordStrength.value = 'wait';
|
|
||||||
passwordAbortController.value = new AbortController();
|
|
||||||
|
|
||||||
const hash = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(source));
|
|
||||||
const hashHex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
const hashPrefix = hashHex.slice(0, 5).toUpperCase();
|
|
||||||
const hashSuffix = hashHex.slice(5).toUpperCase();
|
|
||||||
await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`, { signal: passwordAbortController.value.signal })
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.text();
|
|
||||||
})
|
|
||||||
.then(text => {
|
|
||||||
const lines = text.split('\n');
|
|
||||||
const line = lines.find(l => l.startsWith(hashSuffix));
|
|
||||||
if (line) {
|
|
||||||
leakedCount.value = parseInt(line.split(':')[1]);
|
|
||||||
isLeaked.value = true;
|
|
||||||
strength = 0;
|
|
||||||
} else {
|
|
||||||
isLeaked.value = false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
leakedCount.value = 0;
|
|
||||||
isLeaked.value = false;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
leakedCount.value = 0;
|
|
||||||
isLeaked.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(0, Math.min(1, strength));
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChangeUsername(): void {
|
function onChangeUsername(): void {
|
||||||
if (username.value === '') {
|
if (username.value === '') {
|
||||||
usernameState.value = null;
|
usernameState.value = null;
|
||||||
@ -274,35 +184,14 @@ function onChangeEmail(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onChangePassword(): Promise<void> {
|
|
||||||
if (password.value === '') {
|
|
||||||
passwordStrength.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const strength = await getPasswordStrength(password.value);
|
|
||||||
passwordStrength.value = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
|
|
||||||
|
|
||||||
if (passwordRetypeState.value === 'match') onChangePasswordRetype();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChangePasswordRetype(): void {
|
|
||||||
if (retypedPassword.value === '') {
|
|
||||||
passwordRetypeState.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordRetypeState.value = password.value === retypedPassword.value ? 'match' : 'not-match';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit(): Promise<void> {
|
async function onSubmit(): Promise<void> {
|
||||||
if (submitting.value) return;
|
if (!password.value?.isValid || submitting.value) return;
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await misskeyApi('signup', {
|
await misskeyApi('signup', {
|
||||||
username: username.value,
|
username: username.value,
|
||||||
password: password.value,
|
password: password.value.password,
|
||||||
emailAddress: email.value,
|
emailAddress: email.value,
|
||||||
invitationCode: invitationCode.value,
|
invitationCode: invitationCode.value,
|
||||||
'hcaptcha-response': hCaptchaResponse.value,
|
'hcaptcha-response': hCaptchaResponse.value,
|
||||||
@ -320,7 +209,7 @@ async function onSubmit(): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
const res = await misskeyApi('signin', {
|
const res = await misskeyApi('signin', {
|
||||||
username: username.value,
|
username: username.value,
|
||||||
password: password.value,
|
password: password.value.password,
|
||||||
});
|
});
|
||||||
emit('signup', res);
|
emit('signup', res);
|
||||||
|
|
||||||
@ -354,26 +243,4 @@ async function onSubmit(): Promise<void> {
|
|||||||
.captcha {
|
.captcha {
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hibpLogo {
|
|
||||||
background: linear-gradient(45deg, #616c70, #626262);
|
|
||||||
color: #fefefe;
|
|
||||||
display: inline-flex;
|
|
||||||
padding-left: 8px;
|
|
||||||
margin-left: 4px;
|
|
||||||
font-size: 0.8em;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
align-items: center;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
|
|
||||||
span {
|
|
||||||
background: linear-gradient(45deg, #255e81, #338cac);
|
|
||||||
font-size: 1.4em;
|
|
||||||
padding: 2px 8px;
|
|
||||||
height: auto;
|
|
||||||
margin-left: 8px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -8,20 +8,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||||
<MkSpacer v-if="token" :contentMax="700" :marginMin="16" :marginMax="32">
|
<MkSpacer v-if="token" :contentMax="700" :marginMin="16" :marginMax="32">
|
||||||
<div class="_gaps_m">
|
<div class="_gaps_m">
|
||||||
<MkInput v-model="password" type="password">
|
<MkNewPassword ref="newPassword" :label="i18n.ts.newPassword"/>
|
||||||
<template #prefix><i class="ti ti-lock"></i></template>
|
<MkButton primary :disabled="shouldDisableSubmitting" @click="save">{{ i18n.ts.save }}</MkButton>
|
||||||
<template #label>{{ i18n.ts.newPassword }}</template>
|
|
||||||
</MkInput>
|
|
||||||
|
|
||||||
<MkButton primary @click="save">{{ i18n.ts.save }}</MkButton>
|
|
||||||
</div>
|
</div>
|
||||||
</MkSpacer>
|
</MkSpacer>
|
||||||
</MkStickyContainer>
|
</MkStickyContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent, onMounted, ref, computed } from 'vue';
|
import { defineAsyncComponent, onMounted, ref, computed, shallowRef } from 'vue';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
import MkNewPassword from '@/components/MkNewPassword.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
@ -32,12 +28,20 @@ const props = defineProps<{
|
|||||||
token?: string;
|
token?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const password = ref('');
|
const newPassword = shallowRef<InstanceType<typeof MkNewPassword> | null>(null);
|
||||||
|
const submitting = ref<boolean>(false);
|
||||||
|
|
||||||
|
const shouldDisableSubmitting = computed((): boolean => {
|
||||||
|
return submitting.value || !newPassword.value?.isValid;
|
||||||
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (!newPassword.value?.isValid || submitting.value) return;
|
||||||
|
submitting.value = true;
|
||||||
|
|
||||||
await os.apiWithDialog('reset-password', {
|
await os.apiWithDialog('reset-password', {
|
||||||
token: props.token,
|
token: props.token,
|
||||||
password: password.value,
|
password: newPassword.value.password,
|
||||||
});
|
});
|
||||||
mainRouter.push('/');
|
mainRouter.push('/');
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<div class="_gaps_m">
|
<div class="_gaps_m">
|
||||||
<FormSection first>
|
<FormSection first>
|
||||||
<template #label>{{ i18n.ts.password }}</template>
|
<template #label>{{ i18n.ts.password }}</template>
|
||||||
<MkButton primary @click="change()">{{ i18n.ts.changePassword }}</MkButton>
|
<MkFolder key="changePasswordKey">
|
||||||
|
<template #icon><i class="ti ti-key"></i></template>
|
||||||
|
<template #label>{{ i18n.ts.changePassword }}</template>
|
||||||
|
<div class="_gaps_s">
|
||||||
|
<MkNewPassword ref="newPassword" :label="i18n.ts.newPassword"/>
|
||||||
|
<MkButton primary :disabled="!newPassword?.isValid" @click="changePassword">{{ i18n.ts.save }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</MkFolder>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
<X2fa/>
|
<X2fa/>
|
||||||
@ -40,10 +47,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed, ref, shallowRef } from 'vue';
|
||||||
import X2fa from './2fa.vue';
|
import X2fa from './2fa.vue';
|
||||||
import FormSection from '@/components/form/section.vue';
|
import FormSection from '@/components/form/section.vue';
|
||||||
import FormSlot from '@/components/form/slot.vue';
|
import FormSlot from '@/components/form/slot.vue';
|
||||||
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
import MkNewPassword from '@/components/MkNewPassword.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import MkPagination from '@/components/MkPagination.vue';
|
import MkPagination from '@/components/MkPagination.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
@ -51,33 +60,16 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
|
|||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
|
|
||||||
|
const changePasswordKey = ref(Date.now());
|
||||||
|
const newPassword = shallowRef<InstanceType<typeof MkNewPassword> | null>(null);
|
||||||
|
|
||||||
const pagination = {
|
const pagination = {
|
||||||
endpoint: 'i/signin-history' as const,
|
endpoint: 'i/signin-history' as const,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function change() {
|
async function changePassword() {
|
||||||
const { canceled: canceled2, result: newPassword } = await os.inputText({
|
if (!newPassword.value?.isValid) return;
|
||||||
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;
|
|
||||||
|
|
||||||
if (newPassword !== newPassword2) {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: i18n.ts.retypedNotMatch,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = await os.authenticateDialog();
|
const auth = await os.authenticateDialog();
|
||||||
if (auth.canceled) return;
|
if (auth.canceled) return;
|
||||||
@ -85,7 +77,9 @@ async function change() {
|
|||||||
os.apiWithDialog('i/change-password', {
|
os.apiWithDialog('i/change-password', {
|
||||||
currentPassword: auth.result.password,
|
currentPassword: auth.result.password,
|
||||||
token: auth.result.token,
|
token: auth.result.token,
|
||||||
newPassword,
|
newPassword: newPassword.value.password,
|
||||||
|
}).then(() => {
|
||||||
|
changePasswordKey.value = Date.now();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user