mirror of
https://iceshrimp.dev/limepotato/jormungandr-bite.git
synced 2024-11-25 03:17:38 -07:00
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
56714c28ed
commit
62654341e4
41 changed files with 937 additions and 582 deletions
|
@ -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: "أدخل الرمز الموجود في تطبيقك لإكمال التثبيت."
|
||||||
|
|
|
@ -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 লিখুন:"
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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í"
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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}"
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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": "アカウントの情報を変更する"
|
||||||
|
@ -1882,7 +1896,7 @@ sendModMail: モデレーションノートを送る
|
||||||
deleted: 削除済み
|
deleted: 削除済み
|
||||||
editNote: 投稿を編集
|
editNote: 投稿を編集
|
||||||
edited: 編集済み
|
edited: 編集済み
|
||||||
signupsDisabled:
|
signupsDisabled:
|
||||||
現在、このサーバーでは新規登録が一般開放されていません。招待コードをお持ちの場合には、以下の欄に入力してください。招待コードをお持ちでない場合にも、新規登録を開放している他のサーバーには入れますよ!
|
現在、このサーバーでは新規登録が一般開放されていません。招待コードをお持ちの場合には、以下の欄に入力してください。招待コードをお持ちでない場合にも、新規登録を開放している他のサーバーには入れますよ!
|
||||||
findOtherInstance: 他のサーバーを探す
|
findOtherInstance: 他のサーバーを探す
|
||||||
newer: 新しい投稿
|
newer: 新しい投稿
|
||||||
|
@ -1898,3 +1912,6 @@ antennasDesc: "アンテナでは指定した条件に合致する投稿が表
|
||||||
expandOnNoteClickDesc: オフの場合、右クリックメニューか日付をクリックすることで開けます。
|
expandOnNoteClickDesc: オフの場合、右クリックメニューか日付をクリックすることで開けます。
|
||||||
expandOnNoteClick: クリックで投稿の詳細を開く
|
expandOnNoteClick: クリックで投稿の詳細を開く
|
||||||
clipsDesc: クリップは分類と共有ができるブックマークです。各投稿のメニューからクリップを作成できます。
|
clipsDesc: クリップは分類と共有ができるブックマークです。各投稿のメニューからクリップを作成できます。
|
||||||
|
_dialog:
|
||||||
|
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
|
||||||
|
charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}"
|
||||||
|
|
|
@ -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을 입력하세요:"
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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-код при помощи приложения."
|
||||||
|
|
|
@ -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:"
|
||||||
|
|
|
@ -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": "Змінити дані акаунту"
|
||||||
|
|
|
@ -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:"
|
||||||
|
|
|
@ -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:"
|
||||||
|
|
|
@ -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:"
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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],
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 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 {
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
||||||
|
|
|
@ -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>();
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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[];
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
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,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";
|
||||||
|
|
||||||
|
// メモ: 各エンドポイントはmeUpdatedを発行するため、refreshAccountは不要
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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'}
|
||||||
|
|
Loading…
Reference in a new issue