feat: 🔒 Improve 2FA/keypass experience
Co-authored-by: Tamania <tamaina@hotmail.co.jp> Co-authored-by: Syuilo <syuilotan@yahoo.co.jp>
This commit is contained in:
parent
fbce5d819f
commit
46af585cf7
41 changed files with 937 additions and 582 deletions
|
@ -1049,8 +1049,8 @@ _tutorial:
|
|||
step6_4: "Now go, explore, and have fun!"
|
||||
_2fa:
|
||||
alreadyRegistered: "سجلت سلفًا جهازًا للاستيثاق بعاملين."
|
||||
registerDevice: "سجّل جهازًا جديدًا"
|
||||
registerKey: "تسجيل مفتاح أمان جديد"
|
||||
registerTOTP: "سجّل جهازًا جديدًا"
|
||||
registerSecurityKey: "تسجيل مفتاح أمان جديد"
|
||||
step1: "أولًا ثبّت تطبيق استيثاق على جهازك (مثل {a} و{b})."
|
||||
step2: "امسح رمز الاستجابة السريعة الموجد على الشاشة."
|
||||
step3: "أدخل الرمز الموجود في تطبيقك لإكمال التثبيت."
|
||||
|
|
|
@ -1130,8 +1130,8 @@ _tutorial:
|
|||
step6_4: "Now go, explore, and have fun!"
|
||||
_2fa:
|
||||
alreadyRegistered: "আপনি ইতিমধ্যে একটি 2-ফ্যাক্টর অথেনটিকেশন ডিভাইস নিবন্ধন করেছেন৷"
|
||||
registerDevice: "নতুন ডিভাইস নিবন্ধন করুন"
|
||||
registerKey: "সিকিউরিটি কী নিবন্ধন করুন"
|
||||
registerTOTP: "নতুন ডিভাইস নিবন্ধন করুন"
|
||||
registerSecurityKey: "সিকিউরিটি কী নিবন্ধন করুন"
|
||||
step1: "প্রথমে, আপনার ডিভাইসে {a} বা {b} এর মতো একটি অথেনটিকেশন অ্যাপ ইনস্টল করুন৷"
|
||||
step2: "এরপরে, অ্যাপের সাহায্যে প্রদর্শিত QR কোডটি স্ক্যান করুন।"
|
||||
step2Url: "ডেস্কটপ অ্যাপে, নিম্নলিখিত URL লিখুন:"
|
||||
|
|
|
@ -319,13 +319,13 @@ _sfx:
|
|||
_2fa:
|
||||
step2Url: "També pots inserir aquest enllaç i utilitzes una aplicació d'escriptori:"
|
||||
alreadyRegistered: Ja heu registrat un dispositiu d'autenticació de dos factors.
|
||||
registerDevice: Registrar un dispositiu nou
|
||||
registerTOTP: Registrar un dispositiu nou
|
||||
securityKeyInfo: A més de l'autenticació d'empremta digital o PIN, també podeu configurar
|
||||
l'autenticació mitjançant claus de seguretat de maquinari compatibles amb FIDO2
|
||||
per protegir encara més el vostre compte.
|
||||
step4: A partir d'ara, qualsevol intent d'inici de sessió futur demanarà aquest
|
||||
token d'inici de sessió.
|
||||
registerKey: Registra una clau de seguretat
|
||||
registerSecurityKey: Registra una clau de seguretat
|
||||
step1: En primer lloc, instal·la una aplicació d'autenticació (com ara {a} o {b})
|
||||
al dispositiu.
|
||||
step2: A continuació, escaneja el codi QR que es mostra en aquesta pantalla.
|
||||
|
|
|
@ -698,8 +698,8 @@ _time:
|
|||
minute: "Minut"
|
||||
hour: "Hodin"
|
||||
_2fa:
|
||||
registerDevice: "Přidat zařízení"
|
||||
registerKey: "Přidat bezpečnostní klíč"
|
||||
registerTOTP: "Přidat zařízení"
|
||||
registerSecurityKey: "Přidat bezpečnostní klíč"
|
||||
_weekday:
|
||||
sunday: "Neděle"
|
||||
monday: "Pondělí"
|
||||
|
|
|
@ -1371,8 +1371,8 @@ _tutorial:
|
|||
_2fa:
|
||||
alreadyRegistered: "Du hast bereits ein Gerät für Zwei-Faktor-Authentifizierung
|
||||
registriert."
|
||||
registerDevice: "Neues Gerät registrieren"
|
||||
registerKey: "Neuen Sicherheitsschlüssel registrieren"
|
||||
registerTOTP: "Neues Gerät registrieren"
|
||||
registerSecurityKey: "Neuen Sicherheitsschlüssel registrieren"
|
||||
step1: "Installiere zuerst eine Authentifizierungsapp (z.B. {a} oder {b}) auf deinem
|
||||
Gerät."
|
||||
step2: "Dann, scanne den angezeigten QR-Code mit deinem Gerät."
|
||||
|
|
|
@ -1487,16 +1487,28 @@ _tutorial:
|
|||
step6_4: "Now go, explore, and have fun!"
|
||||
_2fa:
|
||||
alreadyRegistered: "You have already registered a 2-factor authentication device."
|
||||
registerDevice: "Register a new device"
|
||||
registerKey: "Register a security key"
|
||||
registerTOTP: "Register authenticator app"
|
||||
step1: "First, install an authentication app (such as {a} or {b}) on your device."
|
||||
step2: "Then, scan the QR code displayed on this screen."
|
||||
step2Click: "Clicking on this QR code will allow you to register 2FA to your security key or phone authenticator app."
|
||||
step2Url: "You can also enter this URL if you're using a desktop program:"
|
||||
step3Title: "Enter an authentication code"
|
||||
step3: "Enter the token provided by your app to finish setup."
|
||||
step4: "From now on, any future login attempts will ask for such a login token."
|
||||
securityKeyInfo: "Besides fingerprint or PIN authentication, you can also setup
|
||||
authentication via hardware security keys that support FIDO2 to further secure
|
||||
your account."
|
||||
securityKeyNotSupported: "Your browser does not support security keys."
|
||||
registerTOTPBeforeKey: "Please set up an authenticator app to register a security or pass key."
|
||||
securityKeyInfo: "Besides fingerprint or PIN authentication, you can also setup authentication via hardware security keys that support FIDO2 to further secure your account."
|
||||
chromePasskeyNotSupported: "Chrome passkeys are currently not supported."
|
||||
registerSecurityKey: "Register a security or pass key"
|
||||
securityKeyName: "Enter a key name"
|
||||
tapSecurityKey: "Please follow your browser to register the security or pass key"
|
||||
removeKey: "Remove security key"
|
||||
removeKeyConfirm: "Really delete the {name} key?"
|
||||
whyTOTPOnlyRenew: "The authenticator app cannot be removed as long as a security key is registered."
|
||||
renewTOTP: "Reconfigure authenticator app"
|
||||
renewTOTPConfirm: "This will cause verification codes from your previous app to stop working"
|
||||
renewTOTPOk: "Reconfigure"
|
||||
renewTOTPCancel: "Cancel"
|
||||
_permissions:
|
||||
"read:account": "View your account information"
|
||||
"write:account": "Edit your account information"
|
||||
|
@ -2058,3 +2070,7 @@ _experiments:
|
|||
postImportsCaption: "Allows users to import their posts from past Calckey,\
|
||||
\ Misskey, Mastodon, Akkoma, and Pleroma accounts. It may cause slowdowns during\
|
||||
\ load if your queue is bottlenecked."
|
||||
|
||||
_dialog:
|
||||
charactersExceeded: "Max characters exceeded! Current: {current}/Limit: {max}"
|
||||
charactersBelow: "Not enough characters! Current: {current}/Limit: {min}"
|
||||
|
|
|
@ -1331,8 +1331,8 @@ _tutorial:
|
|||
step6_4: "¡Ahora ve, explora y diviértete!"
|
||||
_2fa:
|
||||
alreadyRegistered: "Ya has completado la configuración."
|
||||
registerDevice: "Registrar dispositivo"
|
||||
registerKey: "Registrar clave"
|
||||
registerTOTP: "Registrar dispositivo"
|
||||
registerSecurityKey: "Registrar clave"
|
||||
step1: "Primero, instale en su dispositivo la aplicación de autenticación {a} o\
|
||||
\ {b} u otra."
|
||||
step2: "Luego, escanee con la aplicación el código QR mostrado en pantalla."
|
||||
|
|
|
@ -1262,8 +1262,8 @@ _tutorial:
|
|||
step6_4: "Maintenant, allez-y, explorez et amusez-vous !"
|
||||
_2fa:
|
||||
alreadyRegistered: "Configuration déjà achevée."
|
||||
registerDevice: "Ajouter un nouvel appareil"
|
||||
registerKey: "Enregistrer une clef"
|
||||
registerTOTP: "Ajouter un nouvel appareil"
|
||||
registerSecurityKey: "Enregistrer une clef"
|
||||
step1: "Tout d'abord, installez une application d'authentification, telle que {a}\
|
||||
\ ou {b}, sur votre appareil."
|
||||
step2: "Ensuite, scannez le code QR affiché sur l’écran."
|
||||
|
|
|
@ -1254,8 +1254,8 @@ _tutorial:
|
|||
step7_3: "Semoga berhasil dan bersenang-senanglah! \U0001F680"
|
||||
_2fa:
|
||||
alreadyRegistered: "Kamu telah mendaftarkan perangkat otentikasi dua faktor."
|
||||
registerDevice: "Daftarkan perangkat baru"
|
||||
registerKey: "Daftarkan kunci keamanan baru"
|
||||
registerTOTP: "Daftarkan perangkat baru"
|
||||
registerSecurityKey: "Daftarkan kunci keamanan baru"
|
||||
step1: "Pertama, pasang aplikasi otentikasi (seperti {a} atau {b}) di perangkat\
|
||||
\ kamu."
|
||||
step2: "Lalu, pindai kode QR yang ada di layar."
|
||||
|
|
|
@ -1139,7 +1139,7 @@ _tutorial:
|
|||
Questo però lo fa! È un po' complicato, ma ci riuscirete in poco tempo"
|
||||
step6_4: "Ora andate, esplorate e divertitevi!"
|
||||
_2fa:
|
||||
registerDevice: "Aggiungi dispositivo"
|
||||
registerTOTP: "Aggiungi dispositivo"
|
||||
_permissions:
|
||||
"read:account": "Visualizzare le informazioni dell'account"
|
||||
"write:account": "Modificare le informazioni dell'account"
|
||||
|
|
|
@ -1314,14 +1314,28 @@ _tutorial:
|
|||
step6_4: "これで完了です。お楽しみください!"
|
||||
_2fa:
|
||||
alreadyRegistered: "既に設定は完了しています。"
|
||||
registerDevice: "デバイスを登録"
|
||||
registerKey: "キーを登録"
|
||||
registerTOTP: "認証アプリの設定を開始"
|
||||
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
|
||||
step2: "次に、表示されているQRコードをアプリでスキャンします。"
|
||||
step2Url: "デスクトップアプリでは次のURLを入力します:"
|
||||
step3: "アプリに表示されているトークンを入力して完了です。"
|
||||
step4: "これからログインするときも、同じようにトークンを入力します。"
|
||||
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーもしくは端末の指紋認証やPINを使用してログインするように設定できます。"
|
||||
step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"
|
||||
step2Url: "デスクトップアプリでは次のURIを入力します:"
|
||||
step3Title: "確認コードを入力"
|
||||
step3: "アプリに表示されている確認コード(トークン)を入力して完了です。"
|
||||
step4: "これからログインするときも、同じように確認コードを入力します。"
|
||||
securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。"
|
||||
registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。"
|
||||
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。"
|
||||
chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。"
|
||||
registerSecurityKey: "セキュリティキー・パスキーを登録する"
|
||||
securityKeyName: "キーの名前を入力"
|
||||
tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください"
|
||||
removeKey: "セキュリティキーを削除"
|
||||
removeKeyConfirm: "{name}を削除しますか?"
|
||||
whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。"
|
||||
renewTOTP: "認証アプリを再設定"
|
||||
renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります"
|
||||
renewTOTPOk: "再設定する"
|
||||
renewTOTPCancel: "やめておく"
|
||||
_permissions:
|
||||
"read:account": "アカウントの情報を見る"
|
||||
"write:account": "アカウントの情報を変更する"
|
||||
|
@ -1898,3 +1912,6 @@ antennasDesc: "アンテナでは指定した条件に合致する投稿が表
|
|||
expandOnNoteClickDesc: オフの場合、右クリックメニューか日付をクリックすることで開けます。
|
||||
expandOnNoteClick: クリックで投稿の詳細を開く
|
||||
clipsDesc: クリップは分類と共有ができるブックマークです。各投稿のメニューからクリップを作成できます。
|
||||
_dialog:
|
||||
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
|
||||
charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}"
|
||||
|
|
|
@ -1179,8 +1179,8 @@ _time:
|
|||
day: "일"
|
||||
_2fa:
|
||||
alreadyRegistered: "이미 설정이 완료되었습니다."
|
||||
registerDevice: "디바이스 등록"
|
||||
registerKey: "키를 등록"
|
||||
registerTOTP: "디바이스 등록"
|
||||
registerSecurityKey: "키를 등록"
|
||||
step1: "먼저, {a}나 {b}등의 인증 앱을 사용 중인 디바이스에 설치합니다."
|
||||
step2: "그 후, 표시되어 있는 QR코드를 앱으로 스캔합니다."
|
||||
step2Url: "데스크톱 앱에서는 다음 URL을 입력하세요:"
|
||||
|
|
|
@ -1260,8 +1260,8 @@ _tutorial:
|
|||
step6_4: "A teraz idź, odkrywaj i baw się dobrze!"
|
||||
_2fa:
|
||||
alreadyRegistered: "Zarejestrowałeś już urządzenie do uwierzytelniania dwuskładnikowego."
|
||||
registerDevice: "Zarejestruj nowe urządzenie"
|
||||
registerKey: "Zarejestruj klucz bezpieczeństwa"
|
||||
registerTOTP: "Zarejestruj nowe urządzenie"
|
||||
registerSecurityKey: "Zarejestruj klucz bezpieczeństwa"
|
||||
step1: "Najpierw, zainstaluj aplikację uwierzytelniającą (taką jak {a} lub {b})
|
||||
na swoim urządzeniu."
|
||||
step2: "Następnie, zeskanuje kod QR z ekranu."
|
||||
|
|
|
@ -1249,8 +1249,8 @@ _tutorial:
|
|||
step6_4: "Теперь идите, изучайте и развлекайтесь!"
|
||||
_2fa:
|
||||
alreadyRegistered: "Двухфакторная аутентификация уже настроена."
|
||||
registerDevice: "Зарегистрируйте ваше устройство"
|
||||
registerKey: "Зарегистрировать ключ"
|
||||
registerTOTP: "Зарегистрируйте ваше устройство"
|
||||
registerSecurityKey: "Зарегистрировать ключ"
|
||||
step1: "Прежде всего, установите на устройство приложение для аутентификации, например,\
|
||||
\ {a} или {b}."
|
||||
step2: "Далее отсканируйте отображаемый QR-код при помощи приложения."
|
||||
|
|
|
@ -1196,8 +1196,8 @@ _tutorial:
|
|||
step6_4: "Now go, explore, and have fun!"
|
||||
_2fa:
|
||||
alreadyRegistered: "Už ste zaregistrovali 2-faktorové autentifikačné zariadenie."
|
||||
registerDevice: "Registrovať nové zariadenie"
|
||||
registerKey: "Registrovať bezpečnostný kľúč"
|
||||
registerTOTP: "Registrovať nové zariadenie"
|
||||
registerSecurityKey: "Registrovať bezpečnostný kľúč"
|
||||
step1: "Najprv si nainštalujte autentifikačnú aplikáciu (napríklad {a} alebo {b}) na svoje zariadenie."
|
||||
step2: "Potom, naskenujte QR kód zobrazený na obrazovke."
|
||||
step2Url: "Do aplikácie zadajte nasledujúcu URL adresu:"
|
||||
|
|
|
@ -959,7 +959,7 @@ _tutorial:
|
|||
step6_3: "Кожен сервер працює по-своєму, і не на всіх серверах працює Calckey. Але цей працює! Це трохи складно, але ви швидко розберетеся"
|
||||
step6_4: "Тепер ідіть, вивчайте і розважайтеся!"
|
||||
_2fa:
|
||||
registerKey: "Зареєструвати новий ключ безпеки"
|
||||
registerSecurityKey: "Зареєструвати новий ключ безпеки"
|
||||
_permissions:
|
||||
"read:account": "Переглядати дані профілю"
|
||||
"write:account": "Змінити дані акаунту"
|
||||
|
|
|
@ -1201,8 +1201,8 @@ _tutorial:
|
|||
step6_4: "Now go, explore, and have fun!"
|
||||
_2fa:
|
||||
alreadyRegistered: "Bạn đã đăng ký thiết bị xác minh 2 bước."
|
||||
registerDevice: "Đăng ký một thiết bị"
|
||||
registerKey: "Đăng ký một mã bảo vệ"
|
||||
registerTOTP: "Đăng ký một thiết bị"
|
||||
registerSecurityKey: "Đăng ký một mã bảo vệ"
|
||||
step1: "Trước tiên, hãy cài đặt một ứng dụng xác minh (chẳng hạn như {a} hoặc {b}) trên thiết bị của bạn."
|
||||
step2: "Sau đó, quét mã QR hiển thị trên màn hình này."
|
||||
step2Url: "Bạn cũng có thể nhập URL này nếu sử dụng một chương trình máy tính:"
|
||||
|
|
|
@ -1210,8 +1210,8 @@ _tutorial:
|
|||
step6_4: "现在去学习并享受乐趣!"
|
||||
_2fa:
|
||||
alreadyRegistered: "此设备已被注册"
|
||||
registerDevice: "注册设备"
|
||||
registerKey: "注册密钥"
|
||||
registerTOTP: "注册设备"
|
||||
registerSecurityKey: "注册密钥"
|
||||
step1: "首先,在您的设备上安装验证应用,例如{a}或{b}。"
|
||||
step2: "然后,扫描屏幕上显示的二维码。"
|
||||
step2Url: "在桌面应用程序中输入以下URL:"
|
||||
|
|
|
@ -1219,8 +1219,8 @@ _tutorial:
|
|||
step6_4: "現在開始探索吧!"
|
||||
_2fa:
|
||||
alreadyRegistered: "你已註冊過一個雙重認證的裝置。"
|
||||
registerDevice: "註冊裝置"
|
||||
registerKey: "註冊鍵"
|
||||
registerTOTP: "註冊裝置"
|
||||
registerSecurityKey: "註冊鍵"
|
||||
step1: "首先,在您的設備上安裝二步驗證程式,例如{a}或{b}。"
|
||||
step2: "然後,掃描螢幕上的QR code。"
|
||||
step2Url: "在桌面版應用中,請輸入以下的URL:"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "calckey",
|
||||
"version": "14.0.0-dev46",
|
||||
"version": "14.0.0-dev51",
|
||||
"codename": "aqua",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -101,6 +101,7 @@
|
|||
"nsfwjs": "2.4.2",
|
||||
"oauth": "^0.10.0",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "^9.1.2",
|
||||
"parse5": "7.1.2",
|
||||
"pg": "8.11.0",
|
||||
"private-ip": "2.3.4",
|
||||
|
@ -123,7 +124,6 @@
|
|||
"semver": "7.5.1",
|
||||
"sharp": "0.32.1",
|
||||
"sonic-channel": "^1.3.1",
|
||||
"speakeasy": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"summaly": "2.7.0",
|
||||
"syslog-pro": "1.0.0",
|
||||
|
@ -181,7 +181,6 @@
|
|||
"@types/semver": "7.5.0",
|
||||
"@types/sharp": "0.31.1",
|
||||
"@types/sinonjs__fake-timers": "8.1.2",
|
||||
"@types/speakeasy": "2.0.7",
|
||||
"@types/tinycolor2": "1.4.3",
|
||||
"@types/tmp": "0.2.3",
|
||||
"@types/uuid": "8.3.4",
|
||||
|
|
|
@ -174,6 +174,7 @@ import * as ep___i_2fa_keyDone from "./endpoints/i/2fa/key-done.js";
|
|||
import * as ep___i_2fa_passwordLess from "./endpoints/i/2fa/password-less.js";
|
||||
import * as ep___i_2fa_registerKey from "./endpoints/i/2fa/register-key.js";
|
||||
import * as ep___i_2fa_register from "./endpoints/i/2fa/register.js";
|
||||
import * as ep___i_2fa_updateKey from "./endpoints/i/2fa/update-key.js";
|
||||
import * as ep___i_2fa_removeKey from "./endpoints/i/2fa/remove-key.js";
|
||||
import * as ep___i_2fa_unregister from "./endpoints/i/2fa/unregister.js";
|
||||
import * as ep___i_apps from "./endpoints/i/apps.js";
|
||||
|
@ -528,6 +529,7 @@ const eps = [
|
|||
["i/2fa/password-less", ep___i_2fa_passwordLess],
|
||||
["i/2fa/register-key", ep___i_2fa_registerKey],
|
||||
["i/2fa/register", ep___i_2fa_register],
|
||||
["i/2fa/update-key", ep___i_2fa_updateKey],
|
||||
["i/2fa/remove-key", ep___i_2fa_removeKey],
|
||||
["i/2fa/unregister", ep___i_2fa_unregister],
|
||||
["i/apps", ep___i_apps],
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import * as speakeasy from "speakeasy";
|
||||
import { publishMainStream } from "@/services/stream.js";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import define from "../../../define.js";
|
||||
import { UserProfiles } from "@/models/index.js";
|
||||
import { Users, UserProfiles } from "@/models/index.js";
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
@ -25,13 +26,14 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
throw new Error("二段階認証の設定が開始されていません");
|
||||
}
|
||||
|
||||
const verified = (speakeasy as any).totp.verify({
|
||||
secret: profile.twoFactorTempSecret,
|
||||
encoding: "base32",
|
||||
token: token,
|
||||
const delta = OTPAuth.TOTP.validate({
|
||||
secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
|
||||
digits: 6,
|
||||
token,
|
||||
window: 1,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
if (delta === null) {
|
||||
throw new Error("not verified");
|
||||
}
|
||||
|
||||
|
@ -39,4 +41,11 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
twoFactorSecret: profile.twoFactorTempSecret,
|
||||
twoFactorEnabled: true,
|
||||
});
|
||||
|
||||
const iObj = await Users.pack(user.id, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
});
|
||||
|
||||
publishMainStream(user.id, "meUpdated", iObj);
|
||||
});
|
||||
|
|
|
@ -28,7 +28,7 @@ export const paramDef = {
|
|||
attestationObject: { type: "string" },
|
||||
password: { type: "string" },
|
||||
challengeId: { type: "string" },
|
||||
name: { type: "string" },
|
||||
name: { type: "string", minLength: 1, maxLength: 30 },
|
||||
},
|
||||
required: [
|
||||
"clientDataJSON",
|
||||
|
|
|
@ -1,10 +1,20 @@
|
|||
import define from "../../../define.js";
|
||||
import { UserProfiles } from "@/models/index.js";
|
||||
import { Users, UserProfiles, UserSecurityKeys } from "@/models/index.js";
|
||||
import { publishMainStream } from "@/services/stream.js";
|
||||
import { ApiError } from "../../../error.js";
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
noKey: {
|
||||
message: "No security key.",
|
||||
code: "NO_SECURITY_KEY",
|
||||
id: "f9c54d7f-d4c2-4d3c-9a8g-a70daac86512",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
@ -16,7 +26,36 @@ export const paramDef = {
|
|||
} as const;
|
||||
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
if (ps.value === true) {
|
||||
// セキュリティキーがなければパスワードレスを有効にはできない
|
||||
const keyCount = await UserSecurityKeys.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
lastUsed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (keyCount === 0) {
|
||||
await UserProfiles.update(user.id, {
|
||||
usePasswordLessLogin: false,
|
||||
});
|
||||
|
||||
throw new ApiError(meta.errors.noKey);
|
||||
}
|
||||
}
|
||||
|
||||
await UserProfiles.update(user.id, {
|
||||
usePasswordLessLogin: ps.value,
|
||||
});
|
||||
|
||||
const iObj = await Users.pack(user.id, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
});
|
||||
|
||||
publishMainStream(user.id, "meUpdated", iObj);
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import * as speakeasy from "speakeasy";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import * as QRCode from "qrcode";
|
||||
import config from "@/config/index.js";
|
||||
import { UserProfiles } from "@/models/index.js";
|
||||
|
@ -30,25 +30,24 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
}
|
||||
|
||||
// Generate user's secret key
|
||||
const secret = speakeasy.generateSecret({
|
||||
length: 32,
|
||||
});
|
||||
const secret = new OTPAuth.Secret();
|
||||
|
||||
await UserProfiles.update(user.id, {
|
||||
twoFactorTempSecret: secret.base32,
|
||||
});
|
||||
|
||||
// Get the data URL of the authenticator URL
|
||||
const url = speakeasy.otpauthURL({
|
||||
secret: secret.base32,
|
||||
encoding: "base32",
|
||||
const totp = new OTPAuth.TOTP({
|
||||
secret,
|
||||
digits: 6,
|
||||
label: user.username,
|
||||
issuer: config.host,
|
||||
});
|
||||
const dataUrl = await QRCode.toDataURL(url);
|
||||
const url = totp.toString();
|
||||
const qr = await QRCode.toDataURL(url);
|
||||
|
||||
return {
|
||||
qr: dataUrl,
|
||||
qr,
|
||||
url,
|
||||
secret: secret.base32,
|
||||
label: user.username,
|
||||
|
|
|
@ -34,6 +34,24 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
id: ps.credentialId,
|
||||
});
|
||||
|
||||
// 使われているキーがなくなったらパスワードレスログインをやめる
|
||||
const keyCount = await UserSecurityKeys.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
lastUsed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (keyCount === 0) {
|
||||
await UserProfiles.update(me.id, {
|
||||
usePasswordLessLogin: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Publish meUpdated event
|
||||
publishMainStream(
|
||||
user.id,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { publishMainStream } from "@/services/stream.js";
|
||||
import define from "../../../define.js";
|
||||
import { UserProfiles } from "@/models/index.js";
|
||||
import { Users, UserProfiles } from "@/models/index.js";
|
||||
import { comparePassword } from "@/misc/password.js";
|
||||
|
||||
export const meta = {
|
||||
|
@ -29,5 +30,13 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
await UserProfiles.update(user.id, {
|
||||
twoFactorSecret: null,
|
||||
twoFactorEnabled: false,
|
||||
usePasswordLessLogin: false,
|
||||
});
|
||||
|
||||
const iObj = await Users.pack(user.id, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
});
|
||||
|
||||
publishMainStream(user.id, "meUpdated", iObj);
|
||||
});
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
import { publishMainStream } from "@/services/stream.js";
|
||||
import define from "../../../define.js";
|
||||
import { Users, UserSecurityKeys } from "@/models/index.js";
|
||||
import { ApiError } from "../../../error.js";
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
errors: {
|
||||
noSuchKey: {
|
||||
message: "No such key.",
|
||||
code: "NO_SUCH_KEY",
|
||||
id: "f9c5467f-d492-4d3c-9a8g-a70dacc86512",
|
||||
},
|
||||
|
||||
accessDenied: {
|
||||
message: "You do not have edit privilege of the channel.",
|
||||
code: "ACCESS_DENIED",
|
||||
id: "1fb7cb09-d46a-4fff-b8df-057708cce513",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", minLength: 1, maxLength: 30 },
|
||||
credentialId: { type: "string" },
|
||||
},
|
||||
required: ["name", "credentialId"],
|
||||
} as const;
|
||||
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const key = await UserSecurityKeys.findOneBy({
|
||||
id: ps.credentialId,
|
||||
});
|
||||
|
||||
if (key == null) {
|
||||
throw new ApiError(meta.errors.noSuchKey);
|
||||
}
|
||||
|
||||
if (key.userId !== user.id) {
|
||||
throw new ApiError(meta.errors.accessDenied);
|
||||
}
|
||||
|
||||
await UserSecurityKeys.update(key.id, {
|
||||
name: ps.name,
|
||||
});
|
||||
|
||||
const iObj = await Users.pack(user.id, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
});
|
||||
|
||||
publishMainStream(user.id, "meUpdated", iObj);
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import type Koa from "koa";
|
||||
import * as speakeasy from "speakeasy";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import signin from "../common/signin.js";
|
||||
import config from "@/config/index.js";
|
||||
import {
|
||||
|
@ -136,14 +136,18 @@ export default async (ctx: Koa.Context) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const verified = (speakeasy as any).totp.verify({
|
||||
secret: profile.twoFactorSecret,
|
||||
encoding: "base32",
|
||||
token: token,
|
||||
window: 2,
|
||||
if (profile.twoFactorSecret == null) {
|
||||
throw new Error("Attempted 2FA signin without 2FA enabled.");
|
||||
}
|
||||
|
||||
const delta = OTPAuth.TOTP.validate({
|
||||
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret),
|
||||
digits: 6,
|
||||
token,
|
||||
window: 1,
|
||||
});
|
||||
|
||||
if (verified) {
|
||||
if (delta != null) {
|
||||
signin(ctx, user);
|
||||
return;
|
||||
} else {
|
||||
|
|
|
@ -725,6 +725,7 @@ export type Endpoints = {
|
|||
"i/2fa/password-less": { req: TODO; res: TODO };
|
||||
"i/2fa/register-key": { req: TODO; res: TODO };
|
||||
"i/2fa/register": { req: TODO; res: TODO };
|
||||
"i/2fa/update-key": { req: TODO; res: TODO };
|
||||
"i/2fa/remove-key": { req: TODO; res: TODO };
|
||||
"i/2fa/unregister": { req: TODO; res: TODO };
|
||||
|
||||
|
|
|
@ -53,12 +53,15 @@
|
|||
>
|
||||
<Mfm :text="i18n.ts.password" />
|
||||
</header>
|
||||
<div v-if="text" :class="$style.text"><Mfm :text="text" /></div>
|
||||
<div v-if="text" :class="$style.text">
|
||||
<Mfm :text="text" />
|
||||
</div>
|
||||
<MkInput
|
||||
ref="inputEl"
|
||||
v-if="input && input.type !== 'paragraph'"
|
||||
v-model="inputValue"
|
||||
autofocus
|
||||
:autocomplete="input.autocomplete"
|
||||
:type="input.type == 'search' ? 'search' : input.type || 'text'"
|
||||
:placeholder="input.placeholder || undefined"
|
||||
@keydown="onInputKeydown"
|
||||
|
@ -69,6 +72,22 @@
|
|||
<template v-if="input.type === 'password'" #prefix
|
||||
><i class="ph-password ph-bold ph-lg"></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>
|
||||
<template v-if="input.type === 'search'" #suffix>
|
||||
<button
|
||||
class="_buttonIcon"
|
||||
|
@ -118,6 +137,7 @@
|
|||
inline
|
||||
primary
|
||||
:autofocus="!input && !select"
|
||||
:disabled="okButtonDisabled"
|
||||
@click="ok"
|
||||
>{{
|
||||
showCancelButton || input || select
|
||||
|
@ -139,8 +159,8 @@
|
|||
primary
|
||||
:autofocus="!input && !select"
|
||||
@click="ok"
|
||||
>{{ i18n.ts.yes }}</MkButton
|
||||
>
|
||||
>{{ i18n.ts.yes }}
|
||||
</MkButton>
|
||||
<MkButton
|
||||
v-if="showCancelButton || input || select"
|
||||
inline
|
||||
|
@ -182,7 +202,10 @@ import * as Acct from "calckey-js/built/acct";
|
|||
type Input = {
|
||||
type: HTMLInputElement["type"];
|
||||
placeholder?: string | null;
|
||||
default: any | null;
|
||||
autocomplete?: string;
|
||||
default: string | number | null;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
type Select = {
|
||||
|
@ -245,8 +268,35 @@ const emit = defineEmits<{
|
|||
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
|
||||
const inputValue = ref(props.input?.default || "");
|
||||
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;
|
||||
});
|
||||
|
||||
const inputEl = ref<typeof MkInput>();
|
||||
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
:placeholder="i18n.ts.password"
|
||||
type="password"
|
||||
:with-password-toggle="true"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
data-cy-signin-password
|
||||
>
|
||||
|
@ -90,6 +91,7 @@
|
|||
v-model="password"
|
||||
type="password"
|
||||
:with-password-toggle="true"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
>
|
||||
<template #label>{{ i18n.ts.password }}</template>
|
||||
|
@ -101,7 +103,7 @@
|
|||
v-model="token"
|
||||
type="text"
|
||||
pattern="^[0-9]{6}$"
|
||||
autocomplete="off"
|
||||
autocomplete="one-time-code"
|
||||
:spellcheck="false"
|
||||
required
|
||||
>
|
||||
|
@ -383,10 +385,11 @@ function showSuspendedDialog() {
|
|||
margin: 0 auto 0 auto;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #ddd;
|
||||
background: var(--accentedBg);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
border-radius: 100%;
|
||||
transition: background-image 0.2s ease-in;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,7 +61,7 @@ import { useInterval } from "@/scripts/use-interval";
|
|||
import { i18n } from "@/i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | number;
|
||||
modelValue: string | number | null;
|
||||
type?:
|
||||
| "text"
|
||||
| "number"
|
||||
|
@ -77,7 +77,7 @@ const props = defineProps<{
|
|||
pattern?: string;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
autocomplete?: boolean;
|
||||
autocomplete?: string;
|
||||
spellcheck?: boolean;
|
||||
step?: any;
|
||||
datalist?: string[];
|
||||
|
|
|
@ -1,17 +1,13 @@
|
|||
<template>
|
||||
<div class="vblkjoeq">
|
||||
<label>
|
||||
<div class="label"><slot name="label"></slot></div>
|
||||
<div class="label" @click="focus"><slot name="label"></slot></div>
|
||||
<div
|
||||
ref="container"
|
||||
class="input"
|
||||
:class="{ inline, disabled, focused }"
|
||||
@click.prevent="onClick"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent="show"
|
||||
>
|
||||
<div ref="prefixEl" class="prefix">
|
||||
<slot name="prefix"></slot>
|
||||
</div>
|
||||
<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
|
||||
<select
|
||||
ref="inputEl"
|
||||
v-model="v"
|
||||
|
@ -28,11 +24,16 @@
|
|||
<slot></slot>
|
||||
</select>
|
||||
<div ref="suffixEl" class="suffix">
|
||||
<i class="ph-caret-down ph-bold ph-lg"></i>
|
||||
<i
|
||||
class="ph-caret-down ph-bold ph-lg"
|
||||
:class="[
|
||||
$style.chevron,
|
||||
{ [$style.chevronOpening]: opening },
|
||||
]"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="caption"><slot name="caption"></slot></div>
|
||||
</label>
|
||||
|
||||
<MkButton v-if="manualSave && changed" primary @click="updated"
|
||||
><i class="ph-floppy-disk-back ph-bold ph-lg"></i>
|
||||
|
@ -44,7 +45,6 @@
|
|||
<script lang="ts" setup>
|
||||
import {
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
nextTick,
|
||||
ref,
|
||||
watch,
|
||||
|
@ -59,7 +59,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;
|
||||
|
@ -73,7 +73,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();
|
||||
|
@ -81,6 +81,7 @@ const slots = useSlots();
|
|||
const { modelValue, autofocus } = toRefs(props);
|
||||
const v = ref(modelValue.value);
|
||||
const focused = ref(false);
|
||||
const opening = ref(false);
|
||||
const changed = ref(false);
|
||||
const invalid = ref(false);
|
||||
const filled = computed(() => v.value !== "" && v.value != null);
|
||||
|
@ -88,7 +89,7 @@ const inputEl = ref(null);
|
|||
const prefixEl = ref(null);
|
||||
const suffixEl = ref(null);
|
||||
const container = ref(null);
|
||||
const height = props.small ? 36 : props.large ? 40 : 38;
|
||||
const height = props.small ? 33 : props.large ? 39 : 36;
|
||||
|
||||
const focus = () => inputEl.value.focus();
|
||||
const onInput = (ev) => {
|
||||
|
@ -145,8 +146,9 @@ onMounted(() => {
|
|||
});
|
||||
});
|
||||
|
||||
const onClick = (ev: MouseEvent) => {
|
||||
function show(ev: MouseEvent) {
|
||||
focused.value = true;
|
||||
opening.value = true;
|
||||
|
||||
const menu = [];
|
||||
let options = slots.default!();
|
||||
|
@ -154,7 +156,7 @@ const onClick = (ev: MouseEvent) => {
|
|||
const pushOption = (option: VNode) => {
|
||||
menu.push({
|
||||
text: option.children,
|
||||
active: v.value === option.props.value,
|
||||
active: computed(() => v.value === option.props.value),
|
||||
action: () => {
|
||||
v.value = option.props.value;
|
||||
},
|
||||
|
@ -188,15 +190,17 @@ const onClick = (ev: MouseEvent) => {
|
|||
|
||||
os.popupMenu(menu, container.value, {
|
||||
width: container.value.offsetWidth,
|
||||
onClosing: () => {
|
||||
opening.value = false;
|
||||
},
|
||||
}).then(() => {
|
||||
focused.value = false;
|
||||
});
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vblkjoeq {
|
||||
> label {
|
||||
> .label {
|
||||
font-size: 0.85em;
|
||||
padding: 0 0 8px 0;
|
||||
|
@ -220,8 +224,6 @@ const onClick = (ev: MouseEvent) => {
|
|||
> .input {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
margin-left: 0.2rem;
|
||||
margin-right: 0.2rem;
|
||||
|
||||
&:hover {
|
||||
> .select {
|
||||
|
@ -309,6 +311,15 @@ const onClick = (ev: MouseEvent) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" module>
|
||||
.chevron {
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
|
||||
.chevronOpening {
|
||||
transform: rotateX(180deg);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -22,7 +22,7 @@ const apiClient = new Misskey.api.APIClient({
|
|||
export const api = ((
|
||||
endpoint: string,
|
||||
data: Record<string, any> = {},
|
||||
token?: string | null | undefined,
|
||||
token?: string | null | undefined
|
||||
) => {
|
||||
pendingApiRequestsCount.value++;
|
||||
|
||||
|
@ -36,13 +36,16 @@ export const api = ((
|
|||
: undefined;
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
fetch(endpoint.indexOf("://") > -1 ? endpoint : `${apiUrl}/${endpoint}`, {
|
||||
fetch(
|
||||
endpoint.indexOf("://") > -1 ? endpoint : `${apiUrl}/${endpoint}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
credentials: "omit",
|
||||
cache: "no-cache",
|
||||
headers: authorization ? { authorization } : {},
|
||||
})
|
||||
}
|
||||
)
|
||||
.then(async (res) => {
|
||||
const body = res.status === 204 ? null : await res.json();
|
||||
|
||||
|
@ -65,7 +68,7 @@ export const api = ((
|
|||
export const apiGet = ((
|
||||
endpoint: string,
|
||||
data: Record<string, any> = {},
|
||||
token?: string | null | undefined,
|
||||
token?: string | null | undefined
|
||||
) => {
|
||||
pendingApiRequestsCount.value++;
|
||||
|
||||
|
@ -110,7 +113,7 @@ export const apiGet = ((
|
|||
export const apiWithDialog = ((
|
||||
endpoint: string,
|
||||
data: Record<string, any> = {},
|
||||
token?: string | null | undefined,
|
||||
token?: string | null | undefined
|
||||
) => {
|
||||
const promise = api(endpoint, data, token);
|
||||
promiseDialog(promise, null, (err) => {
|
||||
|
@ -127,7 +130,7 @@ export function promiseDialog<T extends Promise<any>>(
|
|||
promise: T,
|
||||
onSuccess?: ((res: any) => void) | null,
|
||||
onFailure?: ((err: Error) => void) | null,
|
||||
text?: string,
|
||||
text?: string
|
||||
): T {
|
||||
const showing = ref(true);
|
||||
const success = ref(false);
|
||||
|
@ -165,7 +168,7 @@ export function promiseDialog<T extends Promise<any>>(
|
|||
text: text,
|
||||
},
|
||||
{},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
|
||||
return promise;
|
||||
|
@ -186,7 +189,7 @@ const zIndexes = {
|
|||
high: 3000000,
|
||||
};
|
||||
export function claimZIndex(
|
||||
priority: "low" | "middle" | "high" = "low",
|
||||
priority: "low" | "middle" | "high" = "low"
|
||||
): number {
|
||||
zIndexes[priority] += 100;
|
||||
return zIndexes[priority];
|
||||
|
@ -201,7 +204,7 @@ export async function popup(
|
|||
component: Component,
|
||||
props: Record<string, any>,
|
||||
events = {},
|
||||
disposeEvent?: string,
|
||||
disposeEvent?: string
|
||||
) {
|
||||
markRaw(component);
|
||||
|
||||
|
@ -242,7 +245,7 @@ export function pageWindow(path: string) {
|
|||
initialPath: path,
|
||||
},
|
||||
{},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -257,7 +260,7 @@ export function modalPageWindow(path: string) {
|
|||
initialPath: path,
|
||||
},
|
||||
{},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -268,7 +271,7 @@ export function toast(message: string) {
|
|||
message,
|
||||
},
|
||||
{},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -289,7 +292,7 @@ export function alert(props: {
|
|||
resolve();
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -313,7 +316,7 @@ export function confirm(props: {
|
|||
resolve(result ? result : { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -340,7 +343,7 @@ export function yesno(props: {
|
|||
resolve(result ? result : { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -350,7 +353,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 }
|
||||
| {
|
||||
|
@ -360,19 +366,17 @@ export function inputText(props: {
|
|||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(
|
||||
defineAsyncComponent({
|
||||
loader: () => import("@/components/MkDialog.vue"),
|
||||
loadingComponent: MkWaitingDialog,
|
||||
delay: 1000,
|
||||
}),
|
||||
MkDialog,
|
||||
{
|
||||
type: props.type,
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
input: {
|
||||
type: props.type,
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
minLength: props.minLength,
|
||||
maxLength: props.maxLength,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -380,7 +384,7 @@ export function inputText(props: {
|
|||
resolve(result ? result : { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -418,7 +422,7 @@ export function inputParagraph(props: {
|
|||
resolve(result ? result : { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -428,6 +432,7 @@ export function inputNumber(props: {
|
|||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
default?: number | null;
|
||||
autocomplete?: string;
|
||||
}): Promise<
|
||||
| { canceled: true; result: undefined }
|
||||
| {
|
||||
|
@ -448,6 +453,7 @@ export function inputNumber(props: {
|
|||
input: {
|
||||
type: "number",
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
},
|
||||
},
|
||||
|
@ -456,7 +462,7 @@ export function inputNumber(props: {
|
|||
resolve(result ? result : { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -475,11 +481,7 @@ export function inputDate(props: {
|
|||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(
|
||||
defineAsyncComponent({
|
||||
loader: () => import("@/components/MkDialog.vue"),
|
||||
loadingComponent: MkWaitingDialog,
|
||||
delay: 1000,
|
||||
}),
|
||||
MkDialog,
|
||||
{
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
|
@ -492,13 +494,16 @@ export function inputDate(props: {
|
|||
{
|
||||
done: (result) => {
|
||||
resolve(
|
||||
(result && isFinite(new Date(result.result)))
|
||||
? { result: new Date(result.result), canceled: false }
|
||||
: { canceled: true },
|
||||
result
|
||||
? {
|
||||
result: new Date(result.result),
|
||||
canceled: false,
|
||||
}
|
||||
: { canceled: true }
|
||||
);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -524,7 +529,7 @@ export function select<C = any>(
|
|||
}[];
|
||||
}[];
|
||||
}
|
||||
),
|
||||
)
|
||||
): Promise<
|
||||
| { canceled: true; result: undefined }
|
||||
| {
|
||||
|
@ -534,11 +539,7 @@ export function select<C = any>(
|
|||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(
|
||||
defineAsyncComponent({
|
||||
loader: () => import("@/components/MkDialog.vue"),
|
||||
loadingComponent: MkWaitingDialog,
|
||||
delay: 1000,
|
||||
}),
|
||||
MkDialog,
|
||||
{
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
|
@ -553,23 +554,19 @@ export function select<C = any>(
|
|||
resolve(result ? result : { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function success() {
|
||||
export function success(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const showing = ref(true);
|
||||
window.setTimeout(() => {
|
||||
showing.value = false;
|
||||
}, 1000);
|
||||
popup(
|
||||
defineAsyncComponent({
|
||||
loader: () => import("@/components/MkWaitingDialog.vue"),
|
||||
loadingComponent: MkWaitingDialog,
|
||||
delay: 1000,
|
||||
}),
|
||||
MkWaitingDialog,
|
||||
{
|
||||
success: true,
|
||||
showing: showing,
|
||||
|
@ -577,20 +574,16 @@ export function success() {
|
|||
{
|
||||
done: () => resolve(),
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function waiting() {
|
||||
export function waiting(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const showing = ref(true);
|
||||
popup(
|
||||
defineAsyncComponent({
|
||||
loader: () => import("@/components/MkWaitingDialog.vue"),
|
||||
loadingComponent: MkWaitingDialog,
|
||||
delay: 1000,
|
||||
}),
|
||||
MkWaitingDialog,
|
||||
{
|
||||
success: false,
|
||||
showing: showing,
|
||||
|
@ -598,7 +591,7 @@ export function waiting() {
|
|||
{
|
||||
done: () => resolve(),
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -617,7 +610,7 @@ export function form(title, form) {
|
|||
resolve(result);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -636,7 +629,7 @@ export async function selectUser() {
|
|||
resolve(user);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -655,7 +648,7 @@ export async function selectInstance(): Promise<Misskey.entities.Instance> {
|
|||
resolve(instance);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -679,7 +672,7 @@ export async function selectDriveFile(multiple: boolean) {
|
|||
}
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -703,7 +696,7 @@ export async function selectDriveFolder(multiple: boolean) {
|
|||
}
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -725,7 +718,7 @@ export async function pickEmoji(src: HTMLElement | null, opts) {
|
|||
resolve(emoji);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -734,7 +727,7 @@ export async function cropImage(
|
|||
image: Misskey.entities.DriveFile,
|
||||
options: {
|
||||
aspectRatio: number;
|
||||
},
|
||||
}
|
||||
): Promise<Misskey.entities.DriveFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(
|
||||
|
@ -752,7 +745,7 @@ export async function cropImage(
|
|||
resolve(x);
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
"closed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -767,7 +760,7 @@ let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null;
|
|||
export async function openEmojiPicker(
|
||||
src?: HTMLElement,
|
||||
opts,
|
||||
initialTextarea: typeof activeTextarea,
|
||||
initialTextarea: typeof activeTextarea
|
||||
) {
|
||||
if (openingEmojiPicker) return;
|
||||
|
||||
|
@ -783,13 +776,14 @@ export async function openEmojiPicker(
|
|||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
for (const node of Array.from(record.addedNodes).filter(
|
||||
(node) => node instanceof HTMLElement,
|
||||
(node) => node instanceof HTMLElement
|
||||
) as HTMLElement[]) {
|
||||
const textareas = node.querySelectorAll("textarea, input");
|
||||
for (const textarea of Array.from(textareas).filter(
|
||||
(textarea) => textarea.dataset.preventEmojiInsert == null,
|
||||
(textarea) => textarea.dataset.preventEmojiInsert == null
|
||||
)) {
|
||||
if (document.activeElement === textarea) activeTextarea = textarea;
|
||||
if (document.activeElement === textarea)
|
||||
activeTextarea = textarea;
|
||||
textarea.addEventListener("focus", () => {
|
||||
activeTextarea = textarea;
|
||||
});
|
||||
|
@ -827,7 +821,7 @@ export async function openEmojiPicker(
|
|||
openingEmojiPicker = null;
|
||||
observer.disconnect();
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -839,7 +833,7 @@ export function popupMenu(
|
|||
width?: number;
|
||||
viaKeyboard?: boolean;
|
||||
noReturnFocus?: boolean;
|
||||
},
|
||||
}
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let dispose;
|
||||
|
@ -862,7 +856,7 @@ export function popupMenu(
|
|||
resolve();
|
||||
dispose();
|
||||
},
|
||||
},
|
||||
}
|
||||
).then((res) => {
|
||||
dispose = res.dispose;
|
||||
});
|
||||
|
@ -871,7 +865,7 @@ export function popupMenu(
|
|||
|
||||
export function contextMenu(
|
||||
items: MenuItem[] | Ref<MenuItem[]>,
|
||||
ev: MouseEvent,
|
||||
ev: MouseEvent
|
||||
) {
|
||||
ev.preventDefault();
|
||||
return new Promise((resolve, reject) => {
|
||||
|
@ -891,7 +885,7 @@ export function contextMenu(
|
|||
resolve();
|
||||
dispose();
|
||||
},
|
||||
},
|
||||
}
|
||||
).then((res) => {
|
||||
dispose = res.dispose;
|
||||
});
|
||||
|
|
96
packages/client/src/pages/settings/2fa.qrdialog.vue
Normal file
96
packages/client/src/pages/settings/2fa.qrdialog.vue
Normal file
|
@ -0,0 +1,96 @@
|
|||
<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://authpass.app/"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
class="_link"
|
||||
>AuthPass</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>
|
|
@ -1,268 +1,274 @@
|
|||
<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>
|
||||
|
||||
<div v-if="$i" class="_gaps_s">
|
||||
<MkFolder>
|
||||
<template #icon
|
||||
><i class="ph-shield-check ph-bold ph-lg"></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>
|
||||
|
||||
<template v-if="supportsCredentials && $i.twoFactorEnabled">
|
||||
<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)">{{
|
||||
<MkButton v-else @click="unregisterTOTP">{{
|
||||
i18n.ts.unregister
|
||||
}}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MkSwitch
|
||||
v-if="$i.securityKeysList.length > 0"
|
||||
v-model="usePasswordLessLogin"
|
||||
@update:modelValue="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="ph-key ph-bold ph-lg"></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="!supportsCredentials" warn>
|
||||
{{ i18n.ts._2fa.securityKeyNotSupported }}
|
||||
</MkInfo>
|
||||
|
||||
<MkInfo
|
||||
v-if="registration && registration.error"
|
||||
style="margin-bottom: 1rem"
|
||||
v-else-if="supportsCredentials && !$i.twoFactorEnabled"
|
||||
warn
|
||||
>{{ i18n.ts.error }}: {{ registration.error }}</MkInfo
|
||||
>
|
||||
<MkButton
|
||||
v-if="!registration || registration.error"
|
||||
@click="addSecurityKey"
|
||||
>{{ i18n.ts._2fa.registerKey }}</MkButton
|
||||
>
|
||||
{{ i18n.ts._2fa.registerTOTPBeforeKey }}
|
||||
</MkInfo>
|
||||
|
||||
<ol v-if="registration && !registration.error">
|
||||
<li v-if="registration.stage >= 0">
|
||||
{{ i18n.ts.tapSecurityKey }}
|
||||
<i
|
||||
v-if="registration.saving && registration.stage == 0"
|
||||
class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"
|
||||
></i>
|
||||
</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
|
||||
>
|
||||
<i
|
||||
v-if="
|
||||
registration.saving && registration.stage == 1
|
||||
"
|
||||
class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"
|
||||
></i>
|
||||
</MkForm>
|
||||
</li>
|
||||
</ol>
|
||||
</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://authpass.app/"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
class="_link"
|
||||
>AuthPass</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>
|
||||
{{ i18n.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
|
||||
<template v-else>
|
||||
<MkButton primary @click="addSecurityKey">{{
|
||||
i18n.ts._2fa.registerSecurityKey
|
||||
}}</MkButton>
|
||||
</li>
|
||||
</ol>
|
||||
<MkInfo>{{ i18n.ts._2fa.step4 }}</MkInfo>
|
||||
<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="ph-pencil-line ph-bold ph-lg"
|
||||
></i>
|
||||
{{ i18n.ts.rename }}</MkButton
|
||||
>
|
||||
<MkButton danger @click="unregisterKey(key)"
|
||||
><i class="ph-trash ph-bold ph-lg"></i>
|
||||
{{ i18n.ts.unregister }}</MkButton
|
||||
>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</template>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkSwitch
|
||||
:disabled="
|
||||
!$i.twoFactorEnabled || $i.securityKeysList.length === 0
|
||||
"
|
||||
:modelValue="usePasswordLessLogin"
|
||||
@update:modelValue="(v) => updatePasswordLessLogin(v)"
|
||||
>
|
||||
<template #label>{{ i18n.ts.passwordLessLogin }}</template>
|
||||
<template #caption>{{
|
||||
i18n.ts.passwordLessLoginDescription
|
||||
}}</template>
|
||||
</MkSwitch>
|
||||
</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/form/input.vue";
|
||||
import MkSwitch from "@/components/form/switch.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.currentPassword,
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function submit() {
|
||||
os.api("i/2fa/done", {
|
||||
token: token.value,
|
||||
})
|
||||
.then(() => {
|
||||
os.success();
|
||||
$i!.twoFactorEnabled = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
}).catch((error) => {
|
||||
os.alert({
|
||||
type: "error",
|
||||
text: err,
|
||||
text: error,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
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 unregisterKey(key) {
|
||||
os.inputText({
|
||||
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;
|
||||
|
||||
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,
|
||||
autocomplete: "current-password",
|
||||
});
|
||||
if (password.canceled) return;
|
||||
|
||||
await os.apiWithDialog("i/2fa/remove-key", {
|
||||
password: password.result,
|
||||
credentialId: key.id,
|
||||
})
|
||||
.then(() => {
|
||||
usePasswordLessLogin.value = false;
|
||||
updatePasswordLessLogin();
|
||||
})
|
||||
.then(() => {
|
||||
os.success();
|
||||
});
|
||||
os.success();
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
function addSecurityKey() {
|
||||
os.inputText({
|
||||
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"),
|
||||
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: "Calckey",
|
||||
name: "Misskey",
|
||||
},
|
||||
user: {
|
||||
id: byteify($i!.id, "ascii"),
|
||||
|
@ -273,28 +279,32 @@ function addSecurityKey() {
|
|||
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;
|
||||
});
|
||||
}) 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>
|
||||
|
|
|
@ -2,15 +2,12 @@
|
|||
<div class="_formRoot">
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts.password }}</template>
|
||||
<FormButton primary @click="change()">{{
|
||||
<MkButton primary @click="change()">{{
|
||||
i18n.ts.changePassword
|
||||
}}</FormButton>
|
||||
}}</MkButton>
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts.twoStepAuthentication }}</template>
|
||||
<X2fa />
|
||||
</FormSection>
|
||||
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts.signinHistory }}</template>
|
||||
|
@ -43,9 +40,9 @@
|
|||
|
||||
<FormSection>
|
||||
<FormSlot>
|
||||
<FormButton danger @click="regenerateToken"
|
||||
<MkButton danger @click="regenerateToken"
|
||||
><i class="ph-arrows-clockwise ph-bold ph-lg"></i>
|
||||
{{ i18n.ts.regenerateLoginToken }}</FormButton
|
||||
{{ i18n.ts.regenerateLoginToken }}</MkButton
|
||||
>
|
||||
<template #caption>{{
|
||||
i18n.ts.regenerateLoginTokenDescription
|
||||
|
@ -59,7 +56,7 @@
|
|||
import X2fa from "./2fa.vue";
|
||||
import FormSection from "@/components/form/section.vue";
|
||||
import FormSlot from "@/components/form/slot.vue";
|
||||
import FormButton from "@/components/MkButton.vue";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import MkPagination from "@/components/MkPagination.vue";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
|
@ -70,11 +67,12 @@ const pagination = {
|
|||
limit: 5,
|
||||
};
|
||||
|
||||
async function change(): Promise<void> {
|
||||
async function change() {
|
||||
const { canceled: canceled1, result: currentPassword } = await os.inputText(
|
||||
{
|
||||
title: i18n.ts.currentPassword,
|
||||
type: "password",
|
||||
autocomplete: "current-password",
|
||||
}
|
||||
);
|
||||
if (canceled1) return;
|
||||
|
@ -82,12 +80,14 @@ async function change(): Promise<void> {
|
|||
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;
|
||||
|
||||
|
@ -105,13 +105,13 @@ async function change(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
function regenerateToken(): void {
|
||||
function regenerateToken() {
|
||||
os.inputText({
|
||||
title: i18n.ts.password,
|
||||
type: "password",
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
os.api("i/regenerate_token", {
|
||||
os.api("i/regenerate-token", {
|
||||
password: password,
|
||||
});
|
||||
});
|
||||
|
@ -129,7 +129,7 @@ definePageMetadata({
|
|||
|
||||
<style lang="scss" scoped>
|
||||
.timnmucd {
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 6px;
|
||||
|
|
|
@ -287,6 +287,34 @@ hr {
|
|||
}
|
||||
}
|
||||
|
||||
._panel {
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
._margin {
|
||||
margin: var(--margin) 0;
|
||||
}
|
||||
|
||||
._gaps_m {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5em;
|
||||
}
|
||||
|
||||
._gaps_s {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
._gaps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--margin);
|
||||
}
|
||||
|
||||
._inputs {
|
||||
display: flex;
|
||||
margin: 32px 0;
|
||||
|
|
|
@ -294,6 +294,9 @@ importers:
|
|||
os-utils:
|
||||
specifier: 0.0.14
|
||||
version: 0.0.14
|
||||
otpauth:
|
||||
specifier: ^9.1.2
|
||||
version: 9.1.2
|
||||
parse5:
|
||||
specifier: 7.1.2
|
||||
version: 7.1.2
|
||||
|
@ -360,9 +363,6 @@ importers:
|
|||
sonic-channel:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
speakeasy:
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
stringz:
|
||||
specifier: 2.1.0
|
||||
version: 2.1.0
|
||||
|
@ -536,9 +536,6 @@ importers:
|
|||
'@types/sinonjs__fake-timers':
|
||||
specifier: 8.1.2
|
||||
version: 8.1.2
|
||||
'@types/speakeasy':
|
||||
specifier: 2.0.7
|
||||
version: 2.0.7
|
||||
'@types/tinycolor2':
|
||||
specifier: 1.4.3
|
||||
version: 1.4.3
|
||||
|
@ -2649,6 +2646,7 @@ packages:
|
|||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
'@swc/wasm': 1.2.130
|
||||
|
||||
|
@ -2755,6 +2753,7 @@ packages:
|
|||
|
||||
/@swc/wasm@1.2.130:
|
||||
resolution: {integrity: sha512-rNcJsBxS70+pv8YUWwf5fRlWX6JoY/HJc25HD/F8m6Kv7XhJdqPPMhyX6TKkUBPAG7TWlZYoxa+rHAjPy4Cj3Q==}
|
||||
requiresBuild: true
|
||||
|
||||
/@syuilo/aiscript@0.11.1:
|
||||
resolution: {integrity: sha512-chwOIA3yLUKvOB0G611hjLArKTeOWNmTm3lHERSaDW1d+dS6do56naX6Lkwy2UpnwWC0qzeNSgg35elk6t2gZg==}
|
||||
|
@ -3641,12 +3640,6 @@ packages:
|
|||
resolution: {integrity: sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==}
|
||||
dev: true
|
||||
|
||||
/@types/speakeasy@2.0.7:
|
||||
resolution: {integrity: sha512-JEcOhN2SQCoX86ZfiZEe8px84sVJtivBXMZfOVyARTYEj0hrwwbj1nF0FwEL3nJSoEV6uTbcdLllMKBgAYHWCQ==}
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
dev: true
|
||||
|
||||
/@types/stack-utils@2.0.1:
|
||||
resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==}
|
||||
dev: true
|
||||
|
@ -4893,10 +4886,6 @@ packages:
|
|||
/balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
/base32.js@0.0.1:
|
||||
resolution: {integrity: sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==}
|
||||
dev: false
|
||||
|
||||
/base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
|
@ -10060,6 +10049,10 @@ packages:
|
|||
resolution: {integrity: sha512-emiQ05haY9CRj1Ho/LiuCqr/+8RgJuWdiHYNglIg2Qjfz0n+pnUq9I2QHplXuOMO2EnAW1oCGC1++aU5VoWSlw==}
|
||||
dev: false
|
||||
|
||||
/jssha@3.3.0:
|
||||
resolution: {integrity: sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==}
|
||||
dev: false
|
||||
|
||||
/jstransformer@1.0.0:
|
||||
resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==}
|
||||
dependencies:
|
||||
|
@ -11676,6 +11669,12 @@ packages:
|
|||
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
|
||||
dev: true
|
||||
|
||||
/otpauth@9.1.2:
|
||||
resolution: {integrity: sha512-iI5nlVvMFP3aTPdjG/fnC4mhVJ/KZOSnBrvo/VnYHUwlTp9jVLjAe2B3i3pyCH+3/E5jYQRSvuHk/8oas3870g==}
|
||||
dependencies:
|
||||
jssha: 3.3.0
|
||||
dev: false
|
||||
|
||||
/p-cancelable@2.1.1:
|
||||
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -13731,13 +13730,6 @@ packages:
|
|||
resolution: {integrity: sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==}
|
||||
dev: true
|
||||
|
||||
/speakeasy@2.0.0:
|
||||
resolution: {integrity: sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
dependencies:
|
||||
base32.js: 0.0.1
|
||||
dev: false
|
||||
|
||||
/split-string@3.1.0:
|
||||
resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
Loading…
Reference in a new issue