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:
ThatOneCalculator 2023-06-15 16:12:32 -07:00
parent fbce5d819f
commit 46af585cf7
No known key found for this signature in database
GPG Key ID: 8703CACD01000000
41 changed files with 937 additions and 582 deletions

View File

@ -1049,8 +1049,8 @@ _tutorial:
step6_4: "Now go, explore, and have fun!" step6_4: "Now go, explore, and have fun!"
_2fa: _2fa:
alreadyRegistered: "سجلت سلفًا جهازًا للاستيثاق بعاملين." alreadyRegistered: "سجلت سلفًا جهازًا للاستيثاق بعاملين."
registerDevice: "سجّل جهازًا جديدًا" registerTOTP: "سجّل جهازًا جديدًا"
registerKey: "تسجيل مفتاح أمان جديد" registerSecurityKey: "تسجيل مفتاح أمان جديد"
step1: "أولًا ثبّت تطبيق استيثاق على جهازك (مثل {a} و{b})." step1: "أولًا ثبّت تطبيق استيثاق على جهازك (مثل {a} و{b})."
step2: "امسح رمز الاستجابة السريعة الموجد على الشاشة." step2: "امسح رمز الاستجابة السريعة الموجد على الشاشة."
step3: "أدخل الرمز الموجود في تطبيقك لإكمال التثبيت." step3: "أدخل الرمز الموجود في تطبيقك لإكمال التثبيت."

View File

@ -1130,8 +1130,8 @@ _tutorial:
step6_4: "Now go, explore, and have fun!" step6_4: "Now go, explore, and have fun!"
_2fa: _2fa:
alreadyRegistered: "আপনি ইতিমধ্যে একটি 2-ফ্যাক্টর অথেনটিকেশন ডিভাইস নিবন্ধন করেছেন৷" alreadyRegistered: "আপনি ইতিমধ্যে একটি 2-ফ্যাক্টর অথেনটিকেশন ডিভাইস নিবন্ধন করেছেন৷"
registerDevice: "নতুন ডিভাইস নিবন্ধন করুন" registerTOTP: "নতুন ডিভাইস নিবন্ধন করুন"
registerKey: "সিকিউরিটি কী নিবন্ধন করুন" registerSecurityKey: "সিকিউরিটি কী নিবন্ধন করুন"
step1: "প্রথমে, আপনার ডিভাইসে {a} বা {b} এর মতো একটি অথেনটিকেশন অ্যাপ ইনস্টল করুন৷" step1: "প্রথমে, আপনার ডিভাইসে {a} বা {b} এর মতো একটি অথেনটিকেশন অ্যাপ ইনস্টল করুন৷"
step2: "এরপরে, অ্যাপের সাহায্যে প্রদর্শিত QR কোডটি স্ক্যান করুন।" step2: "এরপরে, অ্যাপের সাহায্যে প্রদর্শিত QR কোডটি স্ক্যান করুন।"
step2Url: "ডেস্কটপ অ্যাপে, নিম্নলিখিত URL লিখুন:" step2Url: "ডেস্কটপ অ্যাপে, নিম্নলিখিত URL লিখুন:"

View File

@ -319,13 +319,13 @@ _sfx:
_2fa: _2fa:
step2Url: "També pots inserir aquest enllaç i utilitzes una aplicació d'escriptori:" step2Url: "També pots inserir aquest enllaç i utilitzes una aplicació d'escriptori:"
alreadyRegistered: Ja heu registrat un dispositiu d'autenticació de dos factors. 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 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 l'autenticació mitjançant claus de seguretat de maquinari compatibles amb FIDO2
per protegir encara més el vostre compte. per protegir encara més el vostre compte.
step4: A partir d'ara, qualsevol intent d'inici de sessió futur demanarà aquest step4: A partir d'ara, qualsevol intent d'inici de sessió futur demanarà aquest
token d'inici de sessió. 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}) step1: En primer lloc, instal·la una aplicació d'autenticació (com ara {a} o {b})
al dispositiu. al dispositiu.
step2: A continuació, escaneja el codi QR que es mostra en aquesta pantalla. step2: A continuació, escaneja el codi QR que es mostra en aquesta pantalla.

View File

@ -698,8 +698,8 @@ _time:
minute: "Minut" minute: "Minut"
hour: "Hodin" hour: "Hodin"
_2fa: _2fa:
registerDevice: "Přidat zařízení" registerTOTP: "Přidat zařízení"
registerKey: "Přidat bezpečnostní klíč" registerSecurityKey: "Přidat bezpečnostní klíč"
_weekday: _weekday:
sunday: "Neděle" sunday: "Neděle"
monday: "Pondělí" monday: "Pondělí"

View File

@ -1371,8 +1371,8 @@ _tutorial:
_2fa: _2fa:
alreadyRegistered: "Du hast bereits ein Gerät für Zwei-Faktor-Authentifizierung alreadyRegistered: "Du hast bereits ein Gerät für Zwei-Faktor-Authentifizierung
registriert." registriert."
registerDevice: "Neues Gerät registrieren" registerTOTP: "Neues Gerät registrieren"
registerKey: "Neuen Sicherheitsschlüssel registrieren" registerSecurityKey: "Neuen Sicherheitsschlüssel registrieren"
step1: "Installiere zuerst eine Authentifizierungsapp (z.B. {a} oder {b}) auf deinem step1: "Installiere zuerst eine Authentifizierungsapp (z.B. {a} oder {b}) auf deinem
Gerät." Gerät."
step2: "Dann, scanne den angezeigten QR-Code mit deinem Gerät." step2: "Dann, scanne den angezeigten QR-Code mit deinem Gerät."

View File

@ -1487,16 +1487,28 @@ _tutorial:
step6_4: "Now go, explore, and have fun!" step6_4: "Now go, explore, and have fun!"
_2fa: _2fa:
alreadyRegistered: "You have already registered a 2-factor authentication device." alreadyRegistered: "You have already registered a 2-factor authentication device."
registerDevice: "Register a new device" registerTOTP: "Register authenticator app"
registerKey: "Register a security key"
step1: "First, install an authentication app (such as {a} or {b}) on your device." step1: "First, install an authentication app (such as {a} or {b}) on your device."
step2: "Then, scan the QR code displayed on this screen." 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:" 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." 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." 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 securityKeyNotSupported: "Your browser does not support security keys."
authentication via hardware security keys that support FIDO2 to further secure registerTOTPBeforeKey: "Please set up an authenticator app to register a security or pass key."
your account." 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: _permissions:
"read:account": "View your account information" "read:account": "View your account information"
"write:account": "Edit your account information" "write:account": "Edit your account information"
@ -2058,3 +2070,7 @@ _experiments:
postImportsCaption: "Allows users to import their posts from past Calckey,\ postImportsCaption: "Allows users to import their posts from past Calckey,\
\ Misskey, Mastodon, Akkoma, and Pleroma accounts. It may cause slowdowns during\ \ Misskey, Mastodon, Akkoma, and Pleroma accounts. It may cause slowdowns during\
\ load if your queue is bottlenecked." \ load if your queue is bottlenecked."
_dialog:
charactersExceeded: "Max characters exceeded! Current: {current}/Limit: {max}"
charactersBelow: "Not enough characters! Current: {current}/Limit: {min}"

View File

@ -1331,8 +1331,8 @@ _tutorial:
step6_4: "¡Ahora ve, explora y diviértete!" step6_4: "¡Ahora ve, explora y diviértete!"
_2fa: _2fa:
alreadyRegistered: "Ya has completado la configuración." alreadyRegistered: "Ya has completado la configuración."
registerDevice: "Registrar dispositivo" registerTOTP: "Registrar dispositivo"
registerKey: "Registrar clave" registerSecurityKey: "Registrar clave"
step1: "Primero, instale en su dispositivo la aplicación de autenticación {a} o\ step1: "Primero, instale en su dispositivo la aplicación de autenticación {a} o\
\ {b} u otra." \ {b} u otra."
step2: "Luego, escanee con la aplicación el código QR mostrado en pantalla." step2: "Luego, escanee con la aplicación el código QR mostrado en pantalla."

View File

@ -1262,8 +1262,8 @@ _tutorial:
step6_4: "Maintenant, allez-y, explorez et amusez-vous !" step6_4: "Maintenant, allez-y, explorez et amusez-vous !"
_2fa: _2fa:
alreadyRegistered: "Configuration déjà achevée." alreadyRegistered: "Configuration déjà achevée."
registerDevice: "Ajouter un nouvel appareil" registerTOTP: "Ajouter un nouvel appareil"
registerKey: "Enregistrer une clef" registerSecurityKey: "Enregistrer une clef"
step1: "Tout d'abord, installez une application d'authentification, telle que {a}\ step1: "Tout d'abord, installez une application d'authentification, telle que {a}\
\ ou {b}, sur votre appareil." \ ou {b}, sur votre appareil."
step2: "Ensuite, scannez le code QR affiché sur lécran." step2: "Ensuite, scannez le code QR affiché sur lécran."

View File

@ -1254,8 +1254,8 @@ _tutorial:
step7_3: "Semoga berhasil dan bersenang-senanglah! \U0001F680" step7_3: "Semoga berhasil dan bersenang-senanglah! \U0001F680"
_2fa: _2fa:
alreadyRegistered: "Kamu telah mendaftarkan perangkat otentikasi dua faktor." alreadyRegistered: "Kamu telah mendaftarkan perangkat otentikasi dua faktor."
registerDevice: "Daftarkan perangkat baru" registerTOTP: "Daftarkan perangkat baru"
registerKey: "Daftarkan kunci keamanan baru" registerSecurityKey: "Daftarkan kunci keamanan baru"
step1: "Pertama, pasang aplikasi otentikasi (seperti {a} atau {b}) di perangkat\ step1: "Pertama, pasang aplikasi otentikasi (seperti {a} atau {b}) di perangkat\
\ kamu." \ kamu."
step2: "Lalu, pindai kode QR yang ada di layar." step2: "Lalu, pindai kode QR yang ada di layar."

View File

@ -1139,7 +1139,7 @@ _tutorial:
Questo però lo fa! È un po' complicato, ma ci riuscirete in poco tempo" Questo però lo fa! È un po' complicato, ma ci riuscirete in poco tempo"
step6_4: "Ora andate, esplorate e divertitevi!" step6_4: "Ora andate, esplorate e divertitevi!"
_2fa: _2fa:
registerDevice: "Aggiungi dispositivo" registerTOTP: "Aggiungi dispositivo"
_permissions: _permissions:
"read:account": "Visualizzare le informazioni dell'account" "read:account": "Visualizzare le informazioni dell'account"
"write:account": "Modificare le informazioni dell'account" "write:account": "Modificare le informazioni dell'account"

View File

@ -1314,14 +1314,28 @@ _tutorial:
step6_4: "これで完了です。お楽しみください!" step6_4: "これで完了です。お楽しみください!"
_2fa: _2fa:
alreadyRegistered: "既に設定は完了しています。" alreadyRegistered: "既に設定は完了しています。"
registerDevice: "デバイスを登録" registerTOTP: "認証アプリの設定を開始"
registerKey: "キーを登録"
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。" step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
step2: "次に、表示されているQRコードをアプリでスキャンします。" step2: "次に、表示されているQRコードをアプリでスキャンします。"
step2Url: "デスクトップアプリでは次のURLを入力します:" step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"
step3: "アプリに表示されているトークンを入力して完了です。" step2Url: "デスクトップアプリでは次のURIを入力します:"
step4: "これからログインするときも、同じようにトークンを入力します。" step3Title: "確認コードを入力"
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーもしくは端末の指紋認証やPINを使用してログインするように設定できます。" step3: "アプリに表示されている確認コード(トークン)を入力して完了です。"
step4: "これからログインするときも、同じように確認コードを入力します。"
securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。"
registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。"
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。"
chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。"
registerSecurityKey: "セキュリティキー・パスキーを登録する"
securityKeyName: "キーの名前を入力"
tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください"
removeKey: "セキュリティキーを削除"
removeKeyConfirm: "{name}を削除しますか?"
whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。"
renewTOTP: "認証アプリを再設定"
renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります"
renewTOTPOk: "再設定する"
renewTOTPCancel: "やめておく"
_permissions: _permissions:
"read:account": "アカウントの情報を見る" "read:account": "アカウントの情報を見る"
"write:account": "アカウントの情報を変更する" "write:account": "アカウントの情報を変更する"
@ -1898,3 +1912,6 @@ antennasDesc: "アンテナでは指定した条件に合致する投稿が表
expandOnNoteClickDesc: オフの場合、右クリックメニューか日付をクリックすることで開けます。 expandOnNoteClickDesc: オフの場合、右クリックメニューか日付をクリックすることで開けます。
expandOnNoteClick: クリックで投稿の詳細を開く expandOnNoteClick: クリックで投稿の詳細を開く
clipsDesc: クリップは分類と共有ができるブックマークです。各投稿のメニューからクリップを作成できます。 clipsDesc: クリップは分類と共有ができるブックマークです。各投稿のメニューからクリップを作成できます。
_dialog:
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}"

View File

@ -1179,8 +1179,8 @@ _time:
day: "일" day: "일"
_2fa: _2fa:
alreadyRegistered: "이미 설정이 완료되었습니다." alreadyRegistered: "이미 설정이 완료되었습니다."
registerDevice: "디바이스 등록" registerTOTP: "디바이스 등록"
registerKey: "키를 등록" registerSecurityKey: "키를 등록"
step1: "먼저, {a}나 {b}등의 인증 앱을 사용 중인 디바이스에 설치합니다." step1: "먼저, {a}나 {b}등의 인증 앱을 사용 중인 디바이스에 설치합니다."
step2: "그 후, 표시되어 있는 QR코드를 앱으로 스캔합니다." step2: "그 후, 표시되어 있는 QR코드를 앱으로 스캔합니다."
step2Url: "데스크톱 앱에서는 다음 URL을 입력하세요:" step2Url: "데스크톱 앱에서는 다음 URL을 입력하세요:"

View File

@ -1260,8 +1260,8 @@ _tutorial:
step6_4: "A teraz idź, odkrywaj i baw się dobrze!" step6_4: "A teraz idź, odkrywaj i baw się dobrze!"
_2fa: _2fa:
alreadyRegistered: "Zarejestrowałeś już urządzenie do uwierzytelniania dwuskładnikowego." alreadyRegistered: "Zarejestrowałeś już urządzenie do uwierzytelniania dwuskładnikowego."
registerDevice: "Zarejestruj nowe urządzenie" registerTOTP: "Zarejestruj nowe urządzenie"
registerKey: "Zarejestruj klucz bezpieczeństwa" registerSecurityKey: "Zarejestruj klucz bezpieczeństwa"
step1: "Najpierw, zainstaluj aplikację uwierzytelniającą (taką jak {a} lub {b}) step1: "Najpierw, zainstaluj aplikację uwierzytelniającą (taką jak {a} lub {b})
na swoim urządzeniu." na swoim urządzeniu."
step2: "Następnie, zeskanuje kod QR z ekranu." step2: "Następnie, zeskanuje kod QR z ekranu."

View File

@ -1249,8 +1249,8 @@ _tutorial:
step6_4: "Теперь идите, изучайте и развлекайтесь!" step6_4: "Теперь идите, изучайте и развлекайтесь!"
_2fa: _2fa:
alreadyRegistered: "Двухфакторная аутентификация уже настроена." alreadyRegistered: "Двухфакторная аутентификация уже настроена."
registerDevice: "Зарегистрируйте ваше устройство" registerTOTP: "Зарегистрируйте ваше устройство"
registerKey: "Зарегистрировать ключ" registerSecurityKey: "Зарегистрировать ключ"
step1: "Прежде всего, установите на устройство приложение для аутентификации, например,\ step1: "Прежде всего, установите на устройство приложение для аутентификации, например,\
\ {a} или {b}." \ {a} или {b}."
step2: "Далее отсканируйте отображаемый QR-код при помощи приложения." step2: "Далее отсканируйте отображаемый QR-код при помощи приложения."

View File

@ -1196,8 +1196,8 @@ _tutorial:
step6_4: "Now go, explore, and have fun!" step6_4: "Now go, explore, and have fun!"
_2fa: _2fa:
alreadyRegistered: "Už ste zaregistrovali 2-faktorové autentifikačné zariadenie." alreadyRegistered: "Už ste zaregistrovali 2-faktorové autentifikačné zariadenie."
registerDevice: "Registrovať nové zariadenie" registerTOTP: "Registrovať nové zariadenie"
registerKey: "Registrovať bezpečnostný kľúč" registerSecurityKey: "Registrovať bezpečnostný kľúč"
step1: "Najprv si nainštalujte autentifikačnú aplikáciu (napríklad {a} alebo {b}) na svoje zariadenie." 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." step2: "Potom, naskenujte QR kód zobrazený na obrazovke."
step2Url: "Do aplikácie zadajte nasledujúcu URL adresu:" step2Url: "Do aplikácie zadajte nasledujúcu URL adresu:"

View File

@ -959,7 +959,7 @@ _tutorial:
step6_3: "Кожен сервер працює по-своєму, і не на всіх серверах працює Calckey. Але цей працює! Це трохи складно, але ви швидко розберетеся" step6_3: "Кожен сервер працює по-своєму, і не на всіх серверах працює Calckey. Але цей працює! Це трохи складно, але ви швидко розберетеся"
step6_4: "Тепер ідіть, вивчайте і розважайтеся!" step6_4: "Тепер ідіть, вивчайте і розважайтеся!"
_2fa: _2fa:
registerKey: "Зареєструвати новий ключ безпеки" registerSecurityKey: "Зареєструвати новий ключ безпеки"
_permissions: _permissions:
"read:account": "Переглядати дані профілю" "read:account": "Переглядати дані профілю"
"write:account": "Змінити дані акаунту" "write:account": "Змінити дані акаунту"

View File

@ -1201,8 +1201,8 @@ _tutorial:
step6_4: "Now go, explore, and have fun!" step6_4: "Now go, explore, and have fun!"
_2fa: _2fa:
alreadyRegistered: "Bạn đã đăng ký thiết bị xác minh 2 bước." alreadyRegistered: "Bạn đã đăng ký thiết bị xác minh 2 bước."
registerDevice: "Đăng ký một thiết bị" registerTOTP: "Đăng ký một thiết bị"
registerKey: "Đăng ký một mã bảo vệ" 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." 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." 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:" 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:"

View File

@ -1210,8 +1210,8 @@ _tutorial:
step6_4: "现在去学习并享受乐趣!" step6_4: "现在去学习并享受乐趣!"
_2fa: _2fa:
alreadyRegistered: "此设备已被注册" alreadyRegistered: "此设备已被注册"
registerDevice: "注册设备" registerTOTP: "注册设备"
registerKey: "注册密钥" registerSecurityKey: "注册密钥"
step1: "首先,在您的设备上安装验证应用,例如{a}或{b}。" step1: "首先,在您的设备上安装验证应用,例如{a}或{b}。"
step2: "然后,扫描屏幕上显示的二维码。" step2: "然后,扫描屏幕上显示的二维码。"
step2Url: "在桌面应用程序中输入以下URL" step2Url: "在桌面应用程序中输入以下URL"

View File

@ -1219,8 +1219,8 @@ _tutorial:
step6_4: "現在開始探索吧!" step6_4: "現在開始探索吧!"
_2fa: _2fa:
alreadyRegistered: "你已註冊過一個雙重認證的裝置。" alreadyRegistered: "你已註冊過一個雙重認證的裝置。"
registerDevice: "註冊裝置" registerTOTP: "註冊裝置"
registerKey: "註冊鍵" registerSecurityKey: "註冊鍵"
step1: "首先,在您的設備上安裝二步驗證程式,例如{a}或{b}。" step1: "首先,在您的設備上安裝二步驗證程式,例如{a}或{b}。"
step2: "然後掃描螢幕上的QR code。" step2: "然後掃描螢幕上的QR code。"
step2Url: "在桌面版應用中請輸入以下的URL" step2Url: "在桌面版應用中請輸入以下的URL"

View File

@ -1,6 +1,6 @@
{ {
"name": "calckey", "name": "calckey",
"version": "14.0.0-dev46", "version": "14.0.0-dev51",
"codename": "aqua", "codename": "aqua",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -101,6 +101,7 @@
"nsfwjs": "2.4.2", "nsfwjs": "2.4.2",
"oauth": "^0.10.0", "oauth": "^0.10.0",
"os-utils": "0.0.14", "os-utils": "0.0.14",
"otpauth": "^9.1.2",
"parse5": "7.1.2", "parse5": "7.1.2",
"pg": "8.11.0", "pg": "8.11.0",
"private-ip": "2.3.4", "private-ip": "2.3.4",
@ -123,7 +124,6 @@
"semver": "7.5.1", "semver": "7.5.1",
"sharp": "0.32.1", "sharp": "0.32.1",
"sonic-channel": "^1.3.1", "sonic-channel": "^1.3.1",
"speakeasy": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"summaly": "2.7.0", "summaly": "2.7.0",
"syslog-pro": "1.0.0", "syslog-pro": "1.0.0",
@ -181,7 +181,6 @@
"@types/semver": "7.5.0", "@types/semver": "7.5.0",
"@types/sharp": "0.31.1", "@types/sharp": "0.31.1",
"@types/sinonjs__fake-timers": "8.1.2", "@types/sinonjs__fake-timers": "8.1.2",
"@types/speakeasy": "2.0.7",
"@types/tinycolor2": "1.4.3", "@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3", "@types/tmp": "0.2.3",
"@types/uuid": "8.3.4", "@types/uuid": "8.3.4",

View File

@ -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_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_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_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_removeKey from "./endpoints/i/2fa/remove-key.js";
import * as ep___i_2fa_unregister from "./endpoints/i/2fa/unregister.js"; import * as ep___i_2fa_unregister from "./endpoints/i/2fa/unregister.js";
import * as ep___i_apps from "./endpoints/i/apps.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/password-less", ep___i_2fa_passwordLess],
["i/2fa/register-key", ep___i_2fa_registerKey], ["i/2fa/register-key", ep___i_2fa_registerKey],
["i/2fa/register", ep___i_2fa_register], ["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/remove-key", ep___i_2fa_removeKey],
["i/2fa/unregister", ep___i_2fa_unregister], ["i/2fa/unregister", ep___i_2fa_unregister],
["i/apps", ep___i_apps], ["i/apps", ep___i_apps],

View File

@ -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 define from "../../../define.js";
import { UserProfiles } from "@/models/index.js"; import { Users, UserProfiles } from "@/models/index.js";
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,
@ -25,13 +26,14 @@ export default define(meta, paramDef, async (ps, user) => {
throw new Error("二段階認証の設定が開始されていません"); throw new Error("二段階認証の設定が開始されていません");
} }
const verified = (speakeasy as any).totp.verify({ const delta = OTPAuth.TOTP.validate({
secret: profile.twoFactorTempSecret, secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
encoding: "base32", digits: 6,
token: token, token,
window: 1,
}); });
if (!verified) { if (delta === null) {
throw new Error("not verified"); throw new Error("not verified");
} }
@ -39,4 +41,11 @@ export default define(meta, paramDef, async (ps, user) => {
twoFactorSecret: profile.twoFactorTempSecret, twoFactorSecret: profile.twoFactorTempSecret,
twoFactorEnabled: true, twoFactorEnabled: true,
}); });
const iObj = await Users.pack(user.id, user, {
detail: true,
includeSecrets: true,
});
publishMainStream(user.id, "meUpdated", iObj);
}); });

View File

@ -28,7 +28,7 @@ export const paramDef = {
attestationObject: { type: "string" }, attestationObject: { type: "string" },
password: { type: "string" }, password: { type: "string" },
challengeId: { type: "string" }, challengeId: { type: "string" },
name: { type: "string" }, name: { type: "string", minLength: 1, maxLength: 30 },
}, },
required: [ required: [
"clientDataJSON", "clientDataJSON",

View File

@ -1,10 +1,20 @@
import define from "../../../define.js"; 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 = { export const meta = {
requireCredential: true, requireCredential: true,
secure: true, secure: true,
errors: {
noKey: {
message: "No security key.",
code: "NO_SECURITY_KEY",
id: "f9c54d7f-d4c2-4d3c-9a8g-a70daac86512",
},
},
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -16,7 +26,36 @@ export const paramDef = {
} as const; } as const;
export default define(meta, paramDef, async (ps, user) => { 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, { await UserProfiles.update(user.id, {
usePasswordLessLogin: ps.value, usePasswordLessLogin: ps.value,
}); });
const iObj = await Users.pack(user.id, user, {
detail: true,
includeSecrets: true,
});
publishMainStream(user.id, "meUpdated", iObj);
}); });

View File

@ -1,4 +1,4 @@
import * as speakeasy from "speakeasy"; import * as OTPAuth from "otpauth";
import * as QRCode from "qrcode"; import * as QRCode from "qrcode";
import config from "@/config/index.js"; import config from "@/config/index.js";
import { UserProfiles } from "@/models/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 // Generate user's secret key
const secret = speakeasy.generateSecret({ const secret = new OTPAuth.Secret();
length: 32,
});
await UserProfiles.update(user.id, { await UserProfiles.update(user.id, {
twoFactorTempSecret: secret.base32, twoFactorTempSecret: secret.base32,
}); });
// Get the data URL of the authenticator URL // Get the data URL of the authenticator URL
const url = speakeasy.otpauthURL({ const totp = new OTPAuth.TOTP({
secret: secret.base32, secret,
encoding: "base32", digits: 6,
label: user.username, label: user.username,
issuer: config.host, issuer: config.host,
}); });
const dataUrl = await QRCode.toDataURL(url); const url = totp.toString();
const qr = await QRCode.toDataURL(url);
return { return {
qr: dataUrl, qr,
url, url,
secret: secret.base32, secret: secret.base32,
label: user.username, label: user.username,

View File

@ -34,6 +34,24 @@ export default define(meta, paramDef, async (ps, user) => {
id: ps.credentialId, 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 // Publish meUpdated event
publishMainStream( publishMainStream(
user.id, user.id,

View File

@ -1,5 +1,6 @@
import { publishMainStream } from "@/services/stream.js";
import define from "../../../define.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"; import { comparePassword } from "@/misc/password.js";
export const meta = { export const meta = {
@ -29,5 +30,13 @@ export default define(meta, paramDef, async (ps, user) => {
await UserProfiles.update(user.id, { await UserProfiles.update(user.id, {
twoFactorSecret: null, twoFactorSecret: null,
twoFactorEnabled: false, twoFactorEnabled: false,
usePasswordLessLogin: false,
}); });
const iObj = await Users.pack(user.id, user, {
detail: true,
includeSecrets: true,
});
publishMainStream(user.id, "meUpdated", iObj);
}); });

View File

@ -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);
});

View File

@ -1,5 +1,5 @@
import type Koa from "koa"; import type Koa from "koa";
import * as speakeasy from "speakeasy"; import * as OTPAuth from "otpauth";
import signin from "../common/signin.js"; import signin from "../common/signin.js";
import config from "@/config/index.js"; import config from "@/config/index.js";
import { import {
@ -136,14 +136,18 @@ export default async (ctx: Koa.Context) => {
return; return;
} }
const verified = (speakeasy as any).totp.verify({ if (profile.twoFactorSecret == null) {
secret: profile.twoFactorSecret, throw new Error("Attempted 2FA signin without 2FA enabled.");
encoding: "base32", }
token: token,
window: 2, const delta = OTPAuth.TOTP.validate({
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret),
digits: 6,
token,
window: 1,
}); });
if (verified) { if (delta != null) {
signin(ctx, user); signin(ctx, user);
return; return;
} else { } else {

View File

@ -725,6 +725,7 @@ export type Endpoints = {
"i/2fa/password-less": { req: TODO; res: TODO }; "i/2fa/password-less": { req: TODO; res: TODO };
"i/2fa/register-key": { req: TODO; res: TODO }; "i/2fa/register-key": { req: TODO; res: TODO };
"i/2fa/register": { 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/remove-key": { req: TODO; res: TODO };
"i/2fa/unregister": { req: TODO; res: TODO }; "i/2fa/unregister": { req: TODO; res: TODO };

View File

@ -53,12 +53,15 @@
> >
<Mfm :text="i18n.ts.password" /> <Mfm :text="i18n.ts.password" />
</header> </header>
<div v-if="text" :class="$style.text"><Mfm :text="text" /></div> <div v-if="text" :class="$style.text">
<Mfm :text="text" />
</div>
<MkInput <MkInput
ref="inputEl" ref="inputEl"
v-if="input && input.type !== 'paragraph'" v-if="input && input.type !== 'paragraph'"
v-model="inputValue" v-model="inputValue"
autofocus autofocus
:autocomplete="input.autocomplete"
:type="input.type == 'search' ? 'search' : input.type || 'text'" :type="input.type == 'search' ? 'search' : input.type || 'text'"
:placeholder="input.placeholder || undefined" :placeholder="input.placeholder || undefined"
@keydown="onInputKeydown" @keydown="onInputKeydown"
@ -69,6 +72,22 @@
<template v-if="input.type === 'password'" #prefix <template v-if="input.type === 'password'" #prefix
><i class="ph-password ph-bold ph-lg"></i ><i class="ph-password ph-bold ph-lg"></i
></template> ></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> <template v-if="input.type === 'search'" #suffix>
<button <button
class="_buttonIcon" class="_buttonIcon"
@ -118,6 +137,7 @@
inline inline
primary primary
:autofocus="!input && !select" :autofocus="!input && !select"
:disabled="okButtonDisabled"
@click="ok" @click="ok"
>{{ >{{
showCancelButton || input || select showCancelButton || input || select
@ -139,8 +159,8 @@
primary primary
:autofocus="!input && !select" :autofocus="!input && !select"
@click="ok" @click="ok"
>{{ i18n.ts.yes }}</MkButton >{{ i18n.ts.yes }}
> </MkButton>
<MkButton <MkButton
v-if="showCancelButton || input || select" v-if="showCancelButton || input || select"
inline inline
@ -182,7 +202,10 @@ import * as Acct from "calckey-js/built/acct";
type Input = { type Input = {
type: HTMLInputElement["type"]; type: HTMLInputElement["type"];
placeholder?: string | null; placeholder?: string | null;
default: any | null; autocomplete?: string;
default: string | number | null;
minLength?: number;
maxLength?: number;
}; };
type Select = { type Select = {
@ -245,8 +268,35 @@ const emit = defineEmits<{
const modal = shallowRef<InstanceType<typeof MkModal>>(); const modal = shallowRef<InstanceType<typeof MkModal>>();
const inputValue = ref(props.input?.default || ""); const inputValue = ref<string | number | null>(props.input?.default ?? null);
const selectedValue = ref(props.select?.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>(); const inputEl = ref<typeof MkInput>();

View File

@ -39,6 +39,7 @@
:placeholder="i18n.ts.password" :placeholder="i18n.ts.password"
type="password" type="password"
:with-password-toggle="true" :with-password-toggle="true"
autocomplete="current-password"
required required
data-cy-signin-password data-cy-signin-password
> >
@ -90,6 +91,7 @@
v-model="password" v-model="password"
type="password" type="password"
:with-password-toggle="true" :with-password-toggle="true"
autocomplete="current-password"
required required
> >
<template #label>{{ i18n.ts.password }}</template> <template #label>{{ i18n.ts.password }}</template>
@ -101,7 +103,7 @@
v-model="token" v-model="token"
type="text" type="text"
pattern="^[0-9]{6}$" pattern="^[0-9]{6}$"
autocomplete="off" autocomplete="one-time-code"
:spellcheck="false" :spellcheck="false"
required required
> >
@ -383,10 +385,11 @@ function showSuspendedDialog() {
margin: 0 auto 0 auto; margin: 0 auto 0 auto;
width: 64px; width: 64px;
height: 64px; height: 64px;
background: #ddd; background: var(--accentedBg);
background-position: center; background-position: center;
background-size: cover; background-size: cover;
border-radius: 100%; border-radius: 100%;
transition: background-image 0.2s ease-in;
} }
} }
} }

View File

@ -61,7 +61,7 @@ import { useInterval } from "@/scripts/use-interval";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
modelValue: string | number; modelValue: string | number | null;
type?: type?:
| "text" | "text"
| "number" | "number"
@ -77,7 +77,7 @@ const props = defineProps<{
pattern?: string; pattern?: string;
placeholder?: string; placeholder?: string;
autofocus?: boolean; autofocus?: boolean;
autocomplete?: boolean; autocomplete?: string;
spellcheck?: boolean; spellcheck?: boolean;
step?: any; step?: any;
datalist?: string[]; datalist?: string[];

View File

@ -1,38 +1,39 @@
<template> <template>
<div class="vblkjoeq"> <div class="vblkjoeq">
<label> <div class="label" @click="focus"><slot name="label"></slot></div>
<div class="label"><slot name="label"></slot></div> <div
<div ref="container"
ref="container" class="input"
class="input" :class="{ inline, disabled, focused }"
:class="{ inline, disabled, focused }" @mousedown.prevent="show"
@click.prevent="onClick" >
tabindex="-1" <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
<select
ref="inputEl"
v-model="v"
v-adaptive-border
class="select"
:disabled="disabled"
:required="required"
:readonly="readonly"
:placeholder="placeholder"
@focus="focused = true"
@blur="focused = false"
@input="onInput"
> >
<div ref="prefixEl" class="prefix"> <slot></slot>
<slot name="prefix"></slot> </select>
</div> <div ref="suffixEl" class="suffix">
<select <i
ref="inputEl" class="ph-caret-down ph-bold ph-lg"
v-model="v" :class="[
v-adaptive-border $style.chevron,
class="select" { [$style.chevronOpening]: opening },
:disabled="disabled" ]"
:required="required" ></i>
:readonly="readonly"
:placeholder="placeholder"
@focus="focused = true"
@blur="focused = false"
@input="onInput"
>
<slot></slot>
</select>
<div ref="suffixEl" class="suffix">
<i class="ph-caret-down ph-bold ph-lg"></i>
</div>
</div> </div>
<div class="caption"><slot name="caption"></slot></div> </div>
</label> <div class="caption"><slot name="caption"></slot></div>
<MkButton v-if="manualSave && changed" primary @click="updated" <MkButton v-if="manualSave && changed" primary @click="updated"
><i class="ph-floppy-disk-back ph-bold ph-lg"></i> ><i class="ph-floppy-disk-back ph-bold ph-lg"></i>
@ -44,7 +45,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { import {
onMounted, onMounted,
onUnmounted,
nextTick, nextTick,
ref, ref,
watch, watch,
@ -59,7 +59,7 @@ import { useInterval } from "@/scripts/use-interval";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
const props = defineProps<{ const props = defineProps<{
modelValue: string; modelValue: string | null;
required?: boolean; required?: boolean;
readonly?: boolean; readonly?: boolean;
disabled?: boolean; disabled?: boolean;
@ -73,7 +73,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(ev: "change", _ev: KeyboardEvent): void; (ev: "change", _ev: KeyboardEvent): void;
(ev: "update:modelValue", value: string): void; (ev: "update:modelValue", value: string | null): void;
}>(); }>();
const slots = useSlots(); const slots = useSlots();
@ -81,6 +81,7 @@ const slots = useSlots();
const { modelValue, autofocus } = toRefs(props); const { modelValue, autofocus } = toRefs(props);
const v = ref(modelValue.value); const v = ref(modelValue.value);
const focused = ref(false); const focused = ref(false);
const opening = ref(false);
const changed = ref(false); const changed = ref(false);
const invalid = ref(false); const invalid = ref(false);
const filled = computed(() => v.value !== "" && v.value != null); const filled = computed(() => v.value !== "" && v.value != null);
@ -88,7 +89,7 @@ const inputEl = ref(null);
const prefixEl = ref(null); const prefixEl = ref(null);
const suffixEl = ref(null); const suffixEl = ref(null);
const container = 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 focus = () => inputEl.value.focus();
const onInput = (ev) => { const onInput = (ev) => {
@ -145,8 +146,9 @@ onMounted(() => {
}); });
}); });
const onClick = (ev: MouseEvent) => { function show(ev: MouseEvent) {
focused.value = true; focused.value = true;
opening.value = true;
const menu = []; const menu = [];
let options = slots.default!(); let options = slots.default!();
@ -154,7 +156,7 @@ const onClick = (ev: MouseEvent) => {
const pushOption = (option: VNode) => { const pushOption = (option: VNode) => {
menu.push({ menu.push({
text: option.children, text: option.children,
active: v.value === option.props.value, active: computed(() => v.value === option.props.value),
action: () => { action: () => {
v.value = option.props.value; v.value = option.props.value;
}, },
@ -188,127 +190,136 @@ const onClick = (ev: MouseEvent) => {
os.popupMenu(menu, container.value, { os.popupMenu(menu, container.value, {
width: container.value.offsetWidth, width: container.value.offsetWidth,
onClosing: () => {
opening.value = false;
},
}).then(() => { }).then(() => {
focused.value = false; focused.value = false;
}); });
}; }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.vblkjoeq { .vblkjoeq {
> label { > .label {
> .label { font-size: 0.85em;
font-size: 0.85em; padding: 0 0 8px 0;
padding: 0 0 8px 0; user-select: none;
user-select: none;
&:empty { &:empty {
display: none; display: none;
}
} }
}
> .caption { > .caption {
font-size: 0.85em; font-size: 0.85em;
padding: 8px 0 0 0; padding: 8px 0 0 0;
color: var(--fgTransparentWeak); color: var(--fgTransparentWeak);
&:empty { &:empty {
display: none; display: none;
}
} }
}
> .input { > .input {
position: relative; position: relative;
cursor: pointer; cursor: pointer;
margin-left: 0.2rem;
margin-right: 0.2rem;
&:hover {
> .select {
border-color: var(--inputBorderHover) !important;
}
}
&:hover {
> .select { > .select {
appearance: none; border-color: var(--inputBorderHover) !important;
-webkit-appearance: none; }
display: block; }
height: v-bind("height + 'px'");
width: 100%; > .select {
margin: 0; appearance: none;
padding: 0 12px; -webkit-appearance: none;
font: inherit; display: block;
font-weight: normal; height: v-bind("height + 'px'");
font-size: 1em; width: 100%;
color: var(--fg); margin: 0;
background: var(--panel); padding: 0 12px;
border: solid 1px var(--panel); font: inherit;
border-radius: 6px; font-weight: normal;
outline: none; font-size: 1em;
box-shadow: none; color: var(--fg);
box-sizing: border-box; background: var(--panel);
cursor: pointer; border: solid 1px var(--panel);
transition: border-color 0.1s ease-out; border-radius: 6px;
pointer-events: none; outline: none;
user-select: none; box-shadow: none;
box-sizing: border-box;
cursor: pointer;
transition: border-color 0.1s ease-out;
pointer-events: none;
user-select: none;
}
> .prefix,
> .suffix {
display: flex;
align-items: center;
position: absolute;
z-index: 1;
top: 0;
padding: 0 12px;
font-size: 1em;
height: v-bind("height + 'px'");
pointer-events: none;
&:empty {
display: none;
} }
> .prefix, > * {
> .suffix {
display: flex;
align-items: center;
position: absolute;
z-index: 1;
top: 0;
padding: 0 12px;
font-size: 1em;
height: v-bind("height + 'px'");
pointer-events: none;
&:empty {
display: none;
}
> * {
display: inline-block;
min-width: 16px;
max-width: 150px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
> .prefix {
left: 0;
padding-right: 6px;
}
> .suffix {
right: 0;
padding-left: 6px;
}
&.inline {
display: inline-block; display: inline-block;
margin: 0; min-width: 16px;
max-width: 150px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
} }
}
&.focused { > .prefix {
> select { left: 0;
border-color: var(--accent) !important; padding-right: 6px;
} }
> .suffix {
right: 0;
padding-left: 6px;
}
&.inline {
display: inline-block;
margin: 0;
}
&.focused {
> select {
border-color: var(--accent) !important;
} }
}
&.disabled { &.disabled {
opacity: 0.7; opacity: 0.7;
&, &,
* { * {
cursor: not-allowed !important; cursor: not-allowed !important;
}
} }
} }
} }
} }
</style> </style>
<style lang="scss" module>
.chevron {
transition: transform 0.1s ease-out;
}
.chevronOpening {
transform: rotateX(180deg);
}
</style>

View File

@ -22,7 +22,7 @@ const apiClient = new Misskey.api.APIClient({
export const api = (( export const api = ((
endpoint: string, endpoint: string,
data: Record<string, any> = {}, data: Record<string, any> = {},
token?: string | null | undefined, token?: string | null | undefined
) => { ) => {
pendingApiRequestsCount.value++; pendingApiRequestsCount.value++;
@ -36,13 +36,16 @@ export const api = ((
: undefined; : undefined;
const promise = new Promise((resolve, reject) => { const promise = new Promise((resolve, reject) => {
fetch(endpoint.indexOf("://") > -1 ? endpoint : `${apiUrl}/${endpoint}`, { fetch(
method: "POST", endpoint.indexOf("://") > -1 ? endpoint : `${apiUrl}/${endpoint}`,
body: JSON.stringify(data), {
credentials: "omit", method: "POST",
cache: "no-cache", body: JSON.stringify(data),
headers: authorization ? { authorization } : {}, credentials: "omit",
}) cache: "no-cache",
headers: authorization ? { authorization } : {},
}
)
.then(async (res) => { .then(async (res) => {
const body = res.status === 204 ? null : await res.json(); const body = res.status === 204 ? null : await res.json();
@ -65,7 +68,7 @@ export const api = ((
export const apiGet = (( export const apiGet = ((
endpoint: string, endpoint: string,
data: Record<string, any> = {}, data: Record<string, any> = {},
token?: string | null | undefined, token?: string | null | undefined
) => { ) => {
pendingApiRequestsCount.value++; pendingApiRequestsCount.value++;
@ -110,7 +113,7 @@ export const apiGet = ((
export const apiWithDialog = (( export const apiWithDialog = ((
endpoint: string, endpoint: string,
data: Record<string, any> = {}, data: Record<string, any> = {},
token?: string | null | undefined, token?: string | null | undefined
) => { ) => {
const promise = api(endpoint, data, token); const promise = api(endpoint, data, token);
promiseDialog(promise, null, (err) => { promiseDialog(promise, null, (err) => {
@ -127,7 +130,7 @@ export function promiseDialog<T extends Promise<any>>(
promise: T, promise: T,
onSuccess?: ((res: any) => void) | null, onSuccess?: ((res: any) => void) | null,
onFailure?: ((err: Error) => void) | null, onFailure?: ((err: Error) => void) | null,
text?: string, text?: string
): T { ): T {
const showing = ref(true); const showing = ref(true);
const success = ref(false); const success = ref(false);
@ -165,7 +168,7 @@ export function promiseDialog<T extends Promise<any>>(
text: text, text: text,
}, },
{}, {},
"closed", "closed"
); );
return promise; return promise;
@ -186,7 +189,7 @@ const zIndexes = {
high: 3000000, high: 3000000,
}; };
export function claimZIndex( export function claimZIndex(
priority: "low" | "middle" | "high" = "low", priority: "low" | "middle" | "high" = "low"
): number { ): number {
zIndexes[priority] += 100; zIndexes[priority] += 100;
return zIndexes[priority]; return zIndexes[priority];
@ -201,7 +204,7 @@ export async function popup(
component: Component, component: Component,
props: Record<string, any>, props: Record<string, any>,
events = {}, events = {},
disposeEvent?: string, disposeEvent?: string
) { ) {
markRaw(component); markRaw(component);
@ -242,7 +245,7 @@ export function pageWindow(path: string) {
initialPath: path, initialPath: path,
}, },
{}, {},
"closed", "closed"
); );
} }
@ -257,7 +260,7 @@ export function modalPageWindow(path: string) {
initialPath: path, initialPath: path,
}, },
{}, {},
"closed", "closed"
); );
} }
@ -268,7 +271,7 @@ export function toast(message: string) {
message, message,
}, },
{}, {},
"closed", "closed"
); );
} }
@ -289,7 +292,7 @@ export function alert(props: {
resolve(); resolve();
}, },
}, },
"closed", "closed"
); );
}); });
} }
@ -313,7 +316,7 @@ export function confirm(props: {
resolve(result ? result : { canceled: true }); resolve(result ? result : { canceled: true });
}, },
}, },
"closed", "closed"
); );
}); });
} }
@ -340,7 +343,7 @@ export function yesno(props: {
resolve(result ? result : { canceled: true }); resolve(result ? result : { canceled: true });
}, },
}, },
"closed", "closed"
); );
}); });
} }
@ -350,7 +353,10 @@ export function inputText(props: {
title?: string | null; title?: string | null;
text?: string | null; text?: string | null;
placeholder?: string | null; placeholder?: string | null;
autocomplete?: string;
default?: string | null; default?: string | null;
minLength?: number;
maxLength?: number;
}): Promise< }): Promise<
| { canceled: true; result: undefined } | { canceled: true; result: undefined }
| { | {
@ -360,19 +366,17 @@ export function inputText(props: {
> { > {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup( popup(
defineAsyncComponent({ MkDialog,
loader: () => import("@/components/MkDialog.vue"),
loadingComponent: MkWaitingDialog,
delay: 1000,
}),
{ {
type: props.type,
title: props.title, title: props.title,
text: props.text, text: props.text,
input: { input: {
type: props.type, type: props.type,
placeholder: props.placeholder, placeholder: props.placeholder,
autocomplete: props.autocomplete,
default: props.default, default: props.default,
minLength: props.minLength,
maxLength: props.maxLength,
}, },
}, },
{ {
@ -380,7 +384,7 @@ export function inputText(props: {
resolve(result ? result : { canceled: true }); resolve(result ? result : { canceled: true });
}, },
}, },
"closed", "closed"
); );
}); });
} }
@ -418,7 +422,7 @@ export function inputParagraph(props: {
resolve(result ? result : { canceled: true }); resolve(result ? result : { canceled: true });
}, },
}, },
"closed", "closed"
); );
}); });
} }
@ -428,6 +432,7 @@ export function inputNumber(props: {
text?: string | null; text?: string | null;
placeholder?: string | null; placeholder?: string | null;
default?: number | null; default?: number | null;
autocomplete?: string;
}): Promise< }): Promise<
| { canceled: true; result: undefined } | { canceled: true; result: undefined }
| { | {
@ -448,6 +453,7 @@ export function inputNumber(props: {
input: { input: {
type: "number", type: "number",
placeholder: props.placeholder, placeholder: props.placeholder,
autocomplete: props.autocomplete,
default: props.default, default: props.default,
}, },
}, },
@ -456,7 +462,7 @@ export function inputNumber(props: {
resolve(result ? result : { canceled: true }); resolve(result ? result : { canceled: true });
}, },
}, },
"closed", "closed"
); );
}); });
} }
@ -475,11 +481,7 @@ export function inputDate(props: {
> { > {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup( popup(
defineAsyncComponent({ MkDialog,
loader: () => import("@/components/MkDialog.vue"),
loadingComponent: MkWaitingDialog,
delay: 1000,
}),
{ {
title: props.title, title: props.title,
text: props.text, text: props.text,
@ -492,13 +494,16 @@ export function inputDate(props: {
{ {
done: (result) => { done: (result) => {
resolve( resolve(
(result && isFinite(new Date(result.result))) result
? { result: new Date(result.result), canceled: false } ? {
: { canceled: true }, result: new Date(result.result),
canceled: false,
}
: { canceled: true }
); );
}, },
}, },
"closed", "closed"
); );
}); });
} }
@ -524,7 +529,7 @@ export function select<C = any>(
}[]; }[];
}[]; }[];
} }
), )
): Promise< ): Promise<
| { canceled: true; result: undefined } | { canceled: true; result: undefined }
| { | {
@ -534,11 +539,7 @@ export function select<C = any>(
> { > {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup( popup(
defineAsyncComponent({ MkDialog,
loader: () => import("@/components/MkDialog.vue"),
loadingComponent: MkWaitingDialog,
delay: 1000,
}),
{ {
title: props.title, title: props.title,
text: props.text, text: props.text,
@ -553,23 +554,19 @@ export function select<C = any>(
resolve(result ? result : { canceled: true }); resolve(result ? result : { canceled: true });
}, },
}, },
"closed", "closed"
); );
}); });
} }
export function success() { export function success(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const showing = ref(true); const showing = ref(true);
window.setTimeout(() => { window.setTimeout(() => {
showing.value = false; showing.value = false;
}, 1000); }, 1000);
popup( popup(
defineAsyncComponent({ MkWaitingDialog,
loader: () => import("@/components/MkWaitingDialog.vue"),
loadingComponent: MkWaitingDialog,
delay: 1000,
}),
{ {
success: true, success: true,
showing: showing, showing: showing,
@ -577,20 +574,16 @@ export function success() {
{ {
done: () => resolve(), done: () => resolve(),
}, },
"closed", "closed"
); );
}); });
} }
export function waiting() { export function waiting(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const showing = ref(true); const showing = ref(true);
popup( popup(
defineAsyncComponent({ MkWaitingDialog,
loader: () => import("@/components/MkWaitingDialog.vue"),
loadingComponent: MkWaitingDialog,
delay: 1000,
}),
{ {
success: false, success: false,
showing: showing, showing: showing,
@ -598,7 +591,7 @@ export function waiting() {
{ {
done: () => resolve(), done: () => resolve(),
}, },
"closed", "closed"
); );
}); });
} }
@ -617,7 +610,7 @@ export function form(title, form) {
resolve(result); resolve(result);
}, },
}, },
"closed", "closed"
); );
}); });
} }
@ -636,7 +629,7 @@ export async function selectUser() {
resolve(user); resolve(user);
}, },
}, },
"closed", "closed"
); );
}); });
} }
@ -655,7 +648,7 @@ export async function selectInstance(): Promise<Misskey.entities.Instance> {
resolve(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); resolve(emoji);
}, },
}, },
"closed", "closed"
); );
}); });
} }
@ -734,7 +727,7 @@ export async function cropImage(
image: Misskey.entities.DriveFile, image: Misskey.entities.DriveFile,
options: { options: {
aspectRatio: number; aspectRatio: number;
}, }
): Promise<Misskey.entities.DriveFile> { ): Promise<Misskey.entities.DriveFile> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup( popup(
@ -752,7 +745,7 @@ export async function cropImage(
resolve(x); resolve(x);
}, },
}, },
"closed", "closed"
); );
}); });
} }
@ -767,7 +760,7 @@ let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null;
export async function openEmojiPicker( export async function openEmojiPicker(
src?: HTMLElement, src?: HTMLElement,
opts, opts,
initialTextarea: typeof activeTextarea, initialTextarea: typeof activeTextarea
) { ) {
if (openingEmojiPicker) return; if (openingEmojiPicker) return;
@ -783,13 +776,14 @@ export async function openEmojiPicker(
const observer = new MutationObserver((records) => { const observer = new MutationObserver((records) => {
for (const record of records) { for (const record of records) {
for (const node of Array.from(record.addedNodes).filter( for (const node of Array.from(record.addedNodes).filter(
(node) => node instanceof HTMLElement, (node) => node instanceof HTMLElement
) as HTMLElement[]) { ) as HTMLElement[]) {
const textareas = node.querySelectorAll("textarea, input"); const textareas = node.querySelectorAll("textarea, input");
for (const textarea of Array.from(textareas).filter( 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", () => { textarea.addEventListener("focus", () => {
activeTextarea = textarea; activeTextarea = textarea;
}); });
@ -827,7 +821,7 @@ export async function openEmojiPicker(
openingEmojiPicker = null; openingEmojiPicker = null;
observer.disconnect(); observer.disconnect();
}, },
}, }
); );
} }
@ -839,7 +833,7 @@ export function popupMenu(
width?: number; width?: number;
viaKeyboard?: boolean; viaKeyboard?: boolean;
noReturnFocus?: boolean; noReturnFocus?: boolean;
}, }
) { ) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let dispose; let dispose;
@ -862,7 +856,7 @@ export function popupMenu(
resolve(); resolve();
dispose(); dispose();
}, },
}, }
).then((res) => { ).then((res) => {
dispose = res.dispose; dispose = res.dispose;
}); });
@ -871,7 +865,7 @@ export function popupMenu(
export function contextMenu( export function contextMenu(
items: MenuItem[] | Ref<MenuItem[]>, items: MenuItem[] | Ref<MenuItem[]>,
ev: MouseEvent, ev: MouseEvent
) { ) {
ev.preventDefault(); ev.preventDefault();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -891,7 +885,7 @@ export function contextMenu(
resolve(); resolve();
dispose(); dispose();
}, },
}, }
).then((res) => { ).then((res) => {
dispose = res.dispose; dispose = res.dispose;
}); });

View 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>

View File

@ -1,300 +1,310 @@
<template> <template>
<div> <FormSection :first="first">
<MkButton <template #label>{{ i18n.ts["2fa"] }}</template>
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>
</template>
<template v-if="supportsCredentials && $i.twoFactorEnabled"> <div v-if="$i" class="_gaps_s">
<hr class="totp-method-sep" /> <MkFolder>
<template #icon
<h2 class="heading">{{ i18n.ts.securityKey }}</h2> ><i class="ph-shield-check ph-bold ph-lg"></i
<p>{{ i18n.ts._2fa.securityKeyInfo }}</p> ></template>
<div class="key-list"> <template #label>{{ i18n.ts.totp }}</template>
<div v-for="key in $i.securityKeysList" class="key"> <template #caption>{{ i18n.ts.totpDescription }}</template>
<h3>{{ key.name }}</h3> <div v-if="$i.twoFactorEnabled" class="_gaps_s">
<div class="last-used"> <div v-text="i18n.ts._2fa.alreadyRegistered" />
{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed" /> <template v-if="$i.securityKeysList.length > 0">
</div> <MkButton @click="renewTOTP">{{
<MkButton @click="unregisterKey(key)">{{ i18n.ts._2fa.renewTOTP
}}</MkButton>
<MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo>
</template>
<MkButton v-else @click="unregisterTOTP">{{
i18n.ts.unregister i18n.ts.unregister
}}</MkButton> }}</MkButton>
</div> </div>
</div>
<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-else-if="supportsCredentials && !$i.twoFactorEnabled"
warn
>
{{ i18n.ts._2fa.registerTOTPBeforeKey }}
</MkInfo>
<template v-else>
<MkButton primary @click="addSecurityKey">{{
i18n.ts._2fa.registerSecurityKey
}}</MkButton>
<MkFolder
v-for="key in $i.securityKeysList"
:key="key.id"
>
<template #label>{{ key.name }}</template>
<template #suffix
><I18n :src="i18n.ts.lastUsedAt"
><template #t
><MkTime
:time="
key.lastUsed
" /></template></I18n
></template>
<div class="_buttons">
<MkButton @click="renameKey(key)"
><i
class="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 <MkSwitch
v-if="$i.securityKeysList.length > 0" :disabled="
v-model="usePasswordLessLogin" !$i.twoFactorEnabled || $i.securityKeysList.length === 0
@update:modelValue="updatePasswordLessLogin" "
>{{ i18n.ts.passwordLessLogin }}</MkSwitch :modelValue="usePasswordLessLogin"
@update:modelValue="(v) => updatePasswordLessLogin(v)"
> >
<template #label>{{ i18n.ts.passwordLessLogin }}</template>
<MkInfo <template #caption>{{
v-if="registration && registration.error" i18n.ts.passwordLessLoginDescription
style="margin-bottom: 1rem" }}</template>
warn </MkSwitch>
>{{ i18n.ts.error }}: {{ registration.error }}</MkInfo
>
<MkButton
v-if="!registration || registration.error"
@click="addSecurityKey"
>{{ i18n.ts._2fa.registerKey }}</MkButton
>
<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
}}</MkButton>
</li>
</ol>
<MkInfo>{{ i18n.ts._2fa.step4 }}</MkInfo>
</div> </div>
</div> </FormSection>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from "vue"; import { ref, defineAsyncComponent } from "vue";
import { hostname } from "@/config"; import { hostname } from "@/config";
import { byteify, hexify, stringify } from "@/scripts/2fa"; import { byteify, hexify, stringify } from "@/scripts/2fa";
import MkButton from "@/components/MkButton.vue"; import MkButton from "@/components/MkButton.vue";
import MkInfo from "@/components/MkInfo.vue"; import MkInfo from "@/components/MkInfo.vue";
import MkInput from "@/components/form/input.vue"; import MkSwitch from "@/components/MkSwitch.vue";
import MkSwitch from "@/components/form/switch.vue"; import FormSection from "@/components/form/section.vue";
import MkFolder from "@/components/MkFolder.vue";
import * as os from "@/os"; import * as os from "@/os";
import { $i } from "@/account"; import { $i } from "@/account";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
// : meUpdatedrefreshAccount
withDefaults(
defineProps<{
first?: boolean;
}>(),
{
first: false,
}
);
const twoFactorData = ref<any>(null); const twoFactorData = ref<any>(null);
const supportsCredentials = ref(!!navigator.credentials); const supportsCredentials = ref(!!navigator.credentials);
const usePasswordLessLogin = ref($i!.usePasswordLessLogin); const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin);
const registration = ref<any>(null);
const keyName = ref("");
const token = ref(null);
function register() { async function registerTOTP() {
os.inputText({ const password = await os.inputText({
title: i18n.ts.password, title: i18n.ts._2fa.registerTOTP,
text: i18n.ts.currentPassword,
type: "password", type: "password",
}).then(({ canceled, result: password }) => { autocomplete: "current-password",
if (canceled) return; });
os.api("i/2fa/register", { if (password.canceled) return;
password: password,
}).then((data) => { const twoFactorData = await os.apiWithDialog("i/2fa/register", {
twoFactorData.value = data; 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({ os.inputText({
title: i18n.ts.password, title: i18n.ts.password,
type: "password", type: "password",
autocomplete: "current-password",
}).then(({ canceled, result: password }) => { }).then(({ canceled, result: password }) => {
if (canceled) return; if (canceled) return;
os.api("i/2fa/unregister", { os.apiWithDialog("i/2fa/unregister", {
password: password, password: password,
}) }).catch((error) => {
.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) => {
os.alert({ os.alert({
type: "error", type: "error",
text: err, text: error,
}); });
}); });
});
} }
function registerKey() { function renewTOTP() {
registration.value.saving = true; os.confirm({
os.api("i/2fa/key-done", { type: "question",
password: registration.value.password, title: i18n.ts._2fa.renewTOTP,
name: keyName.value, text: i18n.ts._2fa.renewTOTPConfirm,
challengeId: registration.value.challengeId, okText: i18n.ts._2fa.renewTOTPOk,
cancelText: i18n.ts._2fa.renewTOTPCancel,
}).then(({ canceled }) => {
if (canceled) return;
registerTOTP();
});
}
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",
autocomplete: "current-password",
});
if (password.canceled) return;
await os.apiWithDialog("i/2fa/remove-key", {
password: password.result,
credentialId: key.id,
});
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,
});
}
async function addSecurityKey() {
const password = await os.inputText({
title: i18n.ts.password,
type: "password",
autocomplete: "current-password",
});
if (password.canceled) return;
const challenge: any = await os.apiWithDialog("i/2fa/register-key", {
password: password.result,
});
const name = await os.inputText({
title: i18n.ts._2fa.registerSecurityKey,
text: i18n.ts._2fa.securityKeyName,
type: "text",
minLength: 1,
maxLength: 30,
});
if (name.canceled) return;
const webAuthnCreation = navigator.credentials.create({
publicKey: {
challenge: byteify(challenge.challenge, "base64"),
rp: {
id: hostname,
name: "Misskey",
},
user: {
id: byteify($i!.id, "ascii"),
name: $i!.username,
displayName: $i!.name,
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
timeout: 60000,
attestation: "direct",
},
}) as Promise<
| (PublicKeyCredential & { response: AuthenticatorAttestationResponse })
| null
>;
const credential = await os.promiseDialog(
webAuthnCreation,
null,
() => {}, // reject
i18n.ts._2fa.tapSecurityKey
);
if (!credential) return;
await os.apiWithDialog("i/2fa/key-done", {
password: password.result,
name: name.result,
challengeId: challenge.challengeId,
// we convert each 16 bits to a string to serialise // we convert each 16 bits to a string to serialise
clientDataJSON: stringify( clientDataJSON: stringify(credential.response.clientDataJSON),
registration.value.credential.response.clientDataJSON attestationObject: hexify(credential.response.attestationObject),
),
attestationObject: hexify(
registration.value.credential.response.attestationObject
),
}).then((key) => {
registration.value = null;
key!.lastUsed = new Date();
os.success();
}); });
} }
function unregisterKey(key) { async function updatePasswordLessLogin(value: boolean) {
os.inputText({ await os.apiWithDialog("i/2fa/password-less", {
title: i18n.ts.password, value,
type: "password",
}).then(({ canceled, result: password }) => {
if (canceled) return;
return os
.api("i/2fa/remove-key", {
password,
credentialId: key.id,
})
.then(() => {
usePasswordLessLogin.value = false;
updatePasswordLessLogin();
})
.then(() => {
os.success();
});
});
}
function addSecurityKey() {
os.inputText({
title: i18n.ts.password,
type: "password",
}).then(({ canceled, result: password }) => {
if (canceled) return;
os.api("i/2fa/register-key", {
password,
})
.then((reg) => {
registration.value = {
password,
challengeId: reg!.challengeId,
stage: 0,
publicKeyOptions: {
challenge: byteify(reg!.challenge, "base64"),
rp: {
id: hostname,
name: "Calckey",
},
user: {
id: byteify($i!.id, "ascii"),
name: $i!.username,
displayName: $i!.name,
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
timeout: 60000,
attestation: "direct",
},
saving: true,
};
return navigator.credentials.create({
publicKey: registration.value.publicKeyOptions,
});
})
.then((credential) => {
registration.value.credential = credential;
registration.value.saving = false;
registration.value.stage = 1;
})
.catch((err) => {
console.warn("Error while registering?", err);
registration.value.error = err.message;
registration.value.stage = -1;
});
});
}
async function updatePasswordLessLogin() {
await os.api("i/2fa/password-less", {
value: !!usePasswordLessLogin.value,
}); });
} }
</script> </script>

View File

@ -2,15 +2,12 @@
<div class="_formRoot"> <div class="_formRoot">
<FormSection> <FormSection>
<template #label>{{ i18n.ts.password }}</template> <template #label>{{ i18n.ts.password }}</template>
<FormButton primary @click="change()">{{ <MkButton primary @click="change()">{{
i18n.ts.changePassword i18n.ts.changePassword
}}</FormButton> }}</MkButton>
</FormSection> </FormSection>
<FormSection> <X2fa />
<template #label>{{ i18n.ts.twoStepAuthentication }}</template>
<X2fa />
</FormSection>
<FormSection> <FormSection>
<template #label>{{ i18n.ts.signinHistory }}</template> <template #label>{{ i18n.ts.signinHistory }}</template>
@ -43,9 +40,9 @@
<FormSection> <FormSection>
<FormSlot> <FormSlot>
<FormButton danger @click="regenerateToken" <MkButton danger @click="regenerateToken"
><i class="ph-arrows-clockwise ph-bold ph-lg"></i> ><i class="ph-arrows-clockwise ph-bold ph-lg"></i>
{{ i18n.ts.regenerateLoginToken }}</FormButton {{ i18n.ts.regenerateLoginToken }}</MkButton
> >
<template #caption>{{ <template #caption>{{
i18n.ts.regenerateLoginTokenDescription i18n.ts.regenerateLoginTokenDescription
@ -59,7 +56,7 @@
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 FormButton 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"; import * as os from "@/os";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
@ -70,11 +67,12 @@ const pagination = {
limit: 5, limit: 5,
}; };
async function change(): Promise<void> { async function change() {
const { canceled: canceled1, result: currentPassword } = await os.inputText( const { canceled: canceled1, result: currentPassword } = await os.inputText(
{ {
title: i18n.ts.currentPassword, title: i18n.ts.currentPassword,
type: "password", type: "password",
autocomplete: "current-password",
} }
); );
if (canceled1) return; if (canceled1) return;
@ -82,12 +80,14 @@ async function change(): Promise<void> {
const { canceled: canceled2, result: newPassword } = await os.inputText({ const { canceled: canceled2, result: newPassword } = await os.inputText({
title: i18n.ts.newPassword, title: i18n.ts.newPassword,
type: "password", type: "password",
autocomplete: "new-password",
}); });
if (canceled2) return; if (canceled2) return;
const { canceled: canceled3, result: newPassword2 } = await os.inputText({ const { canceled: canceled3, result: newPassword2 } = await os.inputText({
title: i18n.ts.newPasswordRetype, title: i18n.ts.newPasswordRetype,
type: "password", type: "password",
autocomplete: "new-password",
}); });
if (canceled3) return; if (canceled3) return;
@ -105,13 +105,13 @@ async function change(): Promise<void> {
}); });
} }
function regenerateToken(): void { function regenerateToken() {
os.inputText({ os.inputText({
title: i18n.ts.password, title: i18n.ts.password,
type: "password", type: "password",
}).then(({ canceled, result: password }) => { }).then(({ canceled, result: password }) => {
if (canceled) return; if (canceled) return;
os.api("i/regenerate_token", { os.api("i/regenerate-token", {
password: password, password: password,
}); });
}); });
@ -129,7 +129,7 @@ definePageMetadata({
<style lang="scss" scoped> <style lang="scss" scoped>
.timnmucd { .timnmucd {
padding: 16px; padding: 12px;
&:first-child { &:first-child {
border-top-left-radius: 6px; border-top-left-radius: 6px;

View File

@ -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 { ._inputs {
display: flex; display: flex;
margin: 32px 0; margin: 32px 0;

View File

@ -294,6 +294,9 @@ importers:
os-utils: os-utils:
specifier: 0.0.14 specifier: 0.0.14
version: 0.0.14 version: 0.0.14
otpauth:
specifier: ^9.1.2
version: 9.1.2
parse5: parse5:
specifier: 7.1.2 specifier: 7.1.2
version: 7.1.2 version: 7.1.2
@ -360,9 +363,6 @@ importers:
sonic-channel: sonic-channel:
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.3.1 version: 1.3.1
speakeasy:
specifier: 2.0.0
version: 2.0.0
stringz: stringz:
specifier: 2.1.0 specifier: 2.1.0
version: 2.1.0 version: 2.1.0
@ -536,9 +536,6 @@ importers:
'@types/sinonjs__fake-timers': '@types/sinonjs__fake-timers':
specifier: 8.1.2 specifier: 8.1.2
version: 8.1.2 version: 8.1.2
'@types/speakeasy':
specifier: 2.0.7
version: 2.0.7
'@types/tinycolor2': '@types/tinycolor2':
specifier: 1.4.3 specifier: 1.4.3
version: 1.4.3 version: 1.4.3
@ -2649,6 +2646,7 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
requiresBuild: true
dependencies: dependencies:
'@swc/wasm': 1.2.130 '@swc/wasm': 1.2.130
@ -2755,6 +2753,7 @@ packages:
/@swc/wasm@1.2.130: /@swc/wasm@1.2.130:
resolution: {integrity: sha512-rNcJsBxS70+pv8YUWwf5fRlWX6JoY/HJc25HD/F8m6Kv7XhJdqPPMhyX6TKkUBPAG7TWlZYoxa+rHAjPy4Cj3Q==} resolution: {integrity: sha512-rNcJsBxS70+pv8YUWwf5fRlWX6JoY/HJc25HD/F8m6Kv7XhJdqPPMhyX6TKkUBPAG7TWlZYoxa+rHAjPy4Cj3Q==}
requiresBuild: true
/@syuilo/aiscript@0.11.1: /@syuilo/aiscript@0.11.1:
resolution: {integrity: sha512-chwOIA3yLUKvOB0G611hjLArKTeOWNmTm3lHERSaDW1d+dS6do56naX6Lkwy2UpnwWC0qzeNSgg35elk6t2gZg==} resolution: {integrity: sha512-chwOIA3yLUKvOB0G611hjLArKTeOWNmTm3lHERSaDW1d+dS6do56naX6Lkwy2UpnwWC0qzeNSgg35elk6t2gZg==}
@ -3641,12 +3640,6 @@ packages:
resolution: {integrity: sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==} resolution: {integrity: sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==}
dev: true 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: /@types/stack-utils@2.0.1:
resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==}
dev: true dev: true
@ -4893,10 +4886,6 @@ packages:
/balanced-match@1.0.2: /balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
/base32.js@0.0.1:
resolution: {integrity: sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==}
dev: false
/base64-js@1.5.1: /base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@ -10060,6 +10049,10 @@ packages:
resolution: {integrity: sha512-emiQ05haY9CRj1Ho/LiuCqr/+8RgJuWdiHYNglIg2Qjfz0n+pnUq9I2QHplXuOMO2EnAW1oCGC1++aU5VoWSlw==} resolution: {integrity: sha512-emiQ05haY9CRj1Ho/LiuCqr/+8RgJuWdiHYNglIg2Qjfz0n+pnUq9I2QHplXuOMO2EnAW1oCGC1++aU5VoWSlw==}
dev: false dev: false
/jssha@3.3.0:
resolution: {integrity: sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==}
dev: false
/jstransformer@1.0.0: /jstransformer@1.0.0:
resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==}
dependencies: dependencies:
@ -11676,6 +11669,12 @@ packages:
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
dev: true 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: /p-cancelable@2.1.1:
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -13731,13 +13730,6 @@ packages:
resolution: {integrity: sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==} resolution: {integrity: sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==}
dev: true 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: /split-string@3.1.0:
resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==} resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}