perf: Optimize passkey prompt information (#11798)

This commit is contained in:
ssongliu
2026-02-05 07:08:54 +00:00
committed by GitHub
parent 47e04a14ad
commit 5db2ccbb22
12 changed files with 207 additions and 177 deletions
+1 -1
View File
@@ -1942,7 +1942,7 @@ const message = {
passkey: 'Passkey',
passkeyManage: 'Manage',
passkeyHelper: 'For quick login, up to 5 passkeys can be bound',
passkeyRequireSSL: 'Enable HTTPS to use passkeys',
passkeyRequireSSL: 'Enable SSL with a trusted certificate and bind a domain name before using passkeys',
passkeyNotSupported: 'Current browser or environment does not support passkeys',
passkeyCount: 'Bound {0}/{1}',
passkeyName: 'Name',
+2 -2
View File
@@ -1955,8 +1955,8 @@ const message = {
passkey: 'Passkey',
passkeyManage: 'Administrar',
passkeyHelper: 'Para inicio rápido, se pueden vincular hasta 5 passkeys',
passkeyRequireSSL: 'Habilita HTTPS para usar passkeys',
passkeyNotSupported: 'El navegador o entorno actual no admite Passkey',
passkeyRequireSSL: 'Activa SSL con un certificado de confianza y vincula un dominio para usar passkeys',
passkeyNotSupported: 'El navegador o entorno actual no admite passkeys',
passkeyCount: 'Vinculadas {0}/{1}',
passkeyName: 'Nombre',
passkeyNameHelper: 'Introduce un nombre para distinguir dispositivos',
+1 -1
View File
@@ -1867,7 +1867,7 @@ const message = {
passkey: 'パスキー',
passkeyManage: '管理',
passkeyHelper: '素早くログインするため最大 5 個のパスキーを登録できます',
passkeyRequireSSL: 'HTTPS を有効にするとパスキーを使用できます',
passkeyRequireSSL: '信頼された証明書で SSL を有効化しドメインをバインドしてからパスキーを使用できます',
passkeyNotSupported: '現在のブラウザまたは環境はパスキーに対応していません',
passkeyCount: '登録済み {0}/{1}',
passkeyName: '名称',
+1 -1
View File
@@ -1843,7 +1843,7 @@ const message = {
passkey: '패스키',
passkeyManage: '관리',
passkeyHelper: '빠른 로그인을 위해 최대 5개의 패스키를 등록할 수 있습니다',
passkeyRequireSSL: 'HTTPS를 활성화하면 패스키를 사용할 수 있습니다',
passkeyRequireSSL: '신뢰할 수 있는 인증서로 SSL을 활성화하고 도메인을 바인딩한 후 패스키를 사용할 수 있습니다',
passkeyNotSupported: '현재 브라우저 또는 환경에서 패스키를 지원하지 않습니다',
passkeyCount: '등록됨 {0}/{1}',
passkeyName: '이름',
+1 -1
View File
@@ -1921,7 +1921,7 @@ const message = {
passkey: 'Passkey',
passkeyManage: 'Urus',
passkeyHelper: 'Untuk log masuk pantas, maksimum 5 passkey boleh dipautkan',
passkeyRequireSSL: 'Aktifkan HTTPS untuk menggunakan passkey',
passkeyRequireSSL: 'Aktifkan SSL dengan sijil dipercayai dan ikat nama domain untuk menggunakan passkey',
passkeyNotSupported: 'Pelayar atau persekitaran semasa tidak menyokong passkey',
passkeyCount: 'Dipaut {0}/{1}',
passkeyName: 'Nama',
+1 -1
View File
@@ -1909,7 +1909,7 @@ const message = {
passkey: 'Passkey',
passkeyManage: 'Gerenciar',
passkeyHelper: 'Para login rápido, é possível vincular até 5 passkeys',
passkeyRequireSSL: 'Ative o HTTPS para usar passkeys',
passkeyRequireSSL: 'Ative o SSL com um certificado confiável e vincule um domínio para usar passkeys',
passkeyNotSupported: 'O navegador ou ambiente atual não suporta passkeys',
passkeyCount: 'Vinculadas {0}/{1}',
passkeyName: 'Nome',
+1 -1
View File
@@ -1910,7 +1910,7 @@ const message = {
passkey: 'Passkey',
passkeyManage: 'Управление',
passkeyHelper: 'Для быстрого входа можно привязать до 5 passkey',
passkeyRequireSSL: 'Включите HTTPS, чтобы использовать passkey',
passkeyRequireSSL: 'Включите SSL с доверенным сертификатом и привяжите домен, чтобы использовать passkey',
passkeyNotSupported: 'Текущий браузер или среда не поддерживает passkey',
passkeyCount: 'Привязано {0}/{1}',
passkeyName: 'Название',
+1 -1
View File
@@ -1962,7 +1962,7 @@ const message = {
passkey: 'Passkey',
passkeyManage: 'Yönet',
passkeyHelper: 'Hızlı giriş için en fazla 5 passkey bağlanabilir',
passkeyRequireSSL: 'Passkey kullanmak için HTTPSi etkinleştirin',
passkeyRequireSSL: 'Passkey kullanmak için güvenilir sertifika ile SSLyi etkinleştirin ve alan adını bağlayın',
passkeyNotSupported: 'Mevcut tarayıcı veya ortam passkey desteklemiyor',
passkeyCount: 'Bağlı {0}/{1}',
passkeyName: 'Ad',
+2 -3
View File
@@ -1903,9 +1903,8 @@ const message = {
passkey: '通行密鑰',
passkeyManage: '管理',
passkeyHelper: '用於快速登入最多可綁定 5 個通行密鑰',
passkeyRequireSSL: '啟用 HTTPS 可使用通行密鑰',
passkeyNotSupported:
'目前瀏覽器或環境不支援通行密鑰 \n 請確認您已綁定域名並啟用面板 SSL, 並且訪問時使用了可信的證書',
passkeyRequireSSL: '使用可信證書啟用 SSL 並且設定網域綁定後才可使用通行密鑰',
passkeyNotSupported: '目前瀏覽器或環境不支援通行密鑰',
passkeyCount: '已綁定 {0}/{1}',
passkeyName: '名稱',
passkeyNameHelper: '請輸入用於區分裝置的名稱',
+3 -4
View File
@@ -1950,9 +1950,8 @@ const message = {
passkey: '通行密钥',
passkeyManage: '管理',
passkeyHelper: '用于快速登录最多可绑定 5 个通行密钥',
passkeyRequireSSL: '开启 HTTPS 可使用通行密钥',
passkeyNotSupported:
'当前浏览器或环境不支持通行密钥 \n 请确认您已经设置域名绑定并启用了面板 SSL, 并且访问时使用了可信的证书',
passkeyRequireSSL: '使用可信证书开启 SSL 并且设置域名绑定后才可使用通行密钥',
passkeyNotSupported: '当前浏览器或环境不支持通行密钥',
passkeyCount: '已绑定 {0}/{1}',
passkeyName: '名称',
passkeyNameHelper: '请输入用于区分设备的名称',
@@ -1961,7 +1960,7 @@ const message = {
passkeyLastUsedAt: '最近使用',
passkeyDeleteConfirm: '删除后将无法使用该通行密钥登录是否继续',
passkeyLimit: '最多可绑定 5 个通行密钥',
passkeyFailed: '通行密钥注册失败请确认面板SSL证书是否为可信证书',
passkeyFailed: '通行密钥注册失败请确认面板 SSL 证书是否为可信证书',
sslChangeHelper: 'https 设置修改需要重启服务是否继续',
sslDisable: '禁用',
sslDisableHelper: '禁用 https 服务需要重启面板才能生效是否继续',
+6 -161
View File
@@ -191,38 +191,7 @@
<DomainSetting ref="domainRef" @search="search" />
<AllowIPsSetting ref="allowIPsRef" @search="search" />
<ResponseSetting ref="responseRef" @search="search()" />
<el-dialog v-model="passkeyDialogVisible" :title="$t('setting.passkey')" width="600px">
<div class="mb-4">
<el-form label-position="top">
<el-form-item :label="$t('setting.passkeyName')">
<el-input v-model.trim="passkeyForm.name" :placeholder="$t('setting.passkeyNameHelper')" />
</el-form-item>
<div class="flex items-center">
<el-button type="primary" @click="registerPasskey" :disabled="!canRegisterPasskey">
{{ $t('setting.passkeyAdd') }}
</el-button>
<span class="text-xs text-gray-500 ml-3">{{ passkeyCountText }}</span>
</div>
</el-form>
</div>
<el-table :data="passkeyList" v-loading="passkeyLoading">
<el-table-column prop="name" :label="$t('setting.passkeyName')" min-width="120" />
<el-table-column prop="createdAt" :label="$t('setting.passkeyCreatedAt')" min-width="160" />
<el-table-column :label="$t('setting.passkeyLastUsedAt')" min-width="160">
<template #default="scope">
<span>{{ scope.row.lastUsedAt || '-' }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.operate')" width="120">
<template #default="scope">
<el-button link type="danger" @click="removePasskey(scope.row.id)">
{{ $t('commons.button.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<PasskeySetting ref="passkeyRef" />
</div>
</template>
@@ -238,22 +207,12 @@ import TimeoutSetting from '@/views/setting/safe/timeout/index.vue';
import EntranceSetting from '@/views/setting/safe/entrance/index.vue';
import DomainSetting from '@/views/setting/safe/domain/index.vue';
import AllowIPsSetting from '@/views/setting/safe/allowips/index.vue';
import {
updateSetting,
getSettingInfo,
getSystemAvailable,
updateSSL,
loadSSLInfo,
passkeyRegisterBegin,
passkeyRegisterFinish,
passkeyList as fetchPasskeyList,
passkeyDelete,
} from '@/api/modules/setting';
import PasskeySetting from '@/views/setting/safe/passkey/index.vue';
import { updateSetting, getSettingInfo, getSystemAvailable, updateSSL, loadSSLInfo } from '@/api/modules/setting';
import i18n from '@/lang';
import { MsgError, MsgSuccess } from '@/utils/message';
import { MsgSuccess } from '@/utils/message';
import { Setting } from '@/api/interface/setting';
import { GlobalStore } from '@/store';
import { base64UrlToBuffer, bufferToBase64Url } from '@/utils/util';
const globalStore = GlobalStore();
const loading = ref(false);
@@ -269,6 +228,7 @@ const lastSSL = ref('Disable');
const sslInfo = ref<Setting.SSLInfo>();
const domainRef = ref();
const allowIPsRef = ref();
const passkeyRef = ref();
const mobile = computed(() => {
return globalStore.isMobile();
});
@@ -292,12 +252,7 @@ const form = reactive({
noAuthSettingValue: '200',
});
const passkeyDialogVisible = ref(false);
const passkeyLoading = ref(false);
const passkeyList = ref<Setting.PasskeyInfo[]>([]);
const passkeyForm = reactive({ name: '' });
const passkeySupported = ref(false);
const passkeyMaxCount = 5;
const passkeyHint = computed(() => {
if (form.ssl === 'Disable') {
return i18n.global.t('setting.passkeyRequireSSL');
@@ -307,18 +262,6 @@ const passkeyHint = computed(() => {
}
return i18n.global.t('setting.passkeyHelper');
});
const passkeyCountText = computed(() => {
return i18n.global.t('setting.passkeyCount', [passkeyList.value.length, passkeyMaxCount]);
});
const canRegisterPasskey = computed(() => {
return (
form.ssl !== 'Disable' &&
passkeySupported.value &&
passkeyList.value.length < passkeyMaxCount &&
passkeyForm.name.trim().length > 0
);
});
const unset = ref(i18n.global.t('setting.unSetting'));
const search = async () => {
@@ -397,105 +340,7 @@ const handleMFA = async () => {
};
const openPasskeyDialog = async () => {
passkeyDialogVisible.value = true;
await loadPasskeys();
};
const loadPasskeys = async () => {
passkeyLoading.value = true;
try {
const res = await fetchPasskeyList();
passkeyList.value = res.data || [];
} catch (error) {
passkeyList.value = [];
} finally {
passkeyLoading.value = false;
}
};
const registerPasskey = async () => {
if (form.ssl === 'Disable') {
MsgError(i18n.global.t('setting.passkeyRequireSSL'));
return;
}
if (!passkeySupported.value) {
MsgError(i18n.global.t('setting.passkeyNotSupported'));
return;
}
if (passkeyList.value.length >= passkeyMaxCount) {
MsgError(i18n.global.t('setting.passkeyLimit'));
return;
}
if (!passkeyForm.name.trim()) {
MsgError(i18n.global.t('commons.rule.requiredInput'));
return;
}
passkeyLoading.value = true;
try {
const res = await passkeyRegisterBegin({ name: passkeyForm.name.trim() });
const publicKey = normalizePasskeyCreation(res.data.publicKey);
const credential = (await navigator.credentials.create({ publicKey })) as PublicKeyCredential | null;
if (!credential) {
MsgError(i18n.global.t('setting.passkeyFailed'));
return;
}
const payload = buildPasskeyAttestation(credential);
await passkeyRegisterFinish(payload, res.data.sessionId);
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
passkeyForm.name = '';
await loadPasskeys();
} catch (res: any) {
if (res?.message) {
console.log(res.message);
MsgError(i18n.global.t('setting.passkeyFailed'));
}
} finally {
passkeyLoading.value = false;
}
};
const removePasskey = async (id: string) => {
ElMessageBox.confirm(i18n.global.t('setting.passkeyDeleteConfirm'), i18n.global.t('setting.passkey'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
})
.then(async () => {
passkeyLoading.value = true;
await passkeyDelete(id);
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
await loadPasskeys();
})
.catch(() => {})
.finally(() => {
passkeyLoading.value = false;
});
};
const normalizePasskeyCreation = (publicKey: Record<string, any>): PublicKeyCredentialCreationOptions => {
const request = { ...publicKey };
request.challenge = base64UrlToBuffer(request.challenge);
request.user = { ...request.user, id: base64UrlToBuffer(request.user.id) };
if (request.excludeCredentials && Array.isArray(request.excludeCredentials)) {
request.excludeCredentials = request.excludeCredentials.map((item) => {
return { ...item, id: base64UrlToBuffer(item.id) };
});
}
return request as PublicKeyCredentialCreationOptions;
};
const buildPasskeyAttestation = (credential: PublicKeyCredential) => {
const response = credential.response as AuthenticatorAttestationResponse;
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
attestationObject: bufferToBase64Url(response.attestationObject),
},
clientExtensionResults: credential.getClientExtensionResults(),
authenticatorAttachment: credential.authenticatorAttachment,
};
passkeyRef.value.acceptParams({ sslStatus: form.ssl, supported: passkeySupported.value });
};
const onChangeEntrance = () => {
@@ -0,0 +1,187 @@
<template>
<DrawerPro v-model="drawerVisible" :header="$t('setting.passkey')" @close="handleClose" size="large">
<div>
<el-form label-position="top">
<el-form-item :label="$t('setting.passkeyName')">
<el-input v-model.trim="passkeyForm.name" :placeholder="$t('setting.passkeyNameHelper')" />
</el-form-item>
<el-button type="primary" @click="registerPasskey" :disabled="!canRegisterPasskey">
{{ $t('setting.passkeyAdd') }}
</el-button>
<span class="text-xs text-gray-500 ml-3">{{ passkeyCountText }}</span>
</el-form>
</div>
<el-divider />
<el-table :data="passkeyList" v-loading="passkeyLoading">
<el-table-column prop="name" :label="$t('setting.passkeyName')" min-width="120" />
<el-table-column prop="createdAt" :label="$t('setting.passkeyCreatedAt')" min-width="160" />
<el-table-column :label="$t('setting.passkeyLastUsedAt')" min-width="160">
<template #default="scope">
<span>{{ scope.row.lastUsedAt || '-' }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.operate')" width="120">
<template #default="scope">
<el-button link type="danger" @click="removePasskey(scope.row.id)">
{{ $t('commons.button.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="drawerVisible = false">{{ $t('commons.button.cancel') }}</el-button>
</template>
</DrawerPro>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import {
passkeyRegisterBegin,
passkeyRegisterFinish,
passkeyList as fetchPasskeyList,
passkeyDelete,
} from '@/api/modules/setting';
import i18n from '@/lang';
import { MsgError, MsgSuccess } from '@/utils/message';
import { Setting } from '@/api/interface/setting';
import { base64UrlToBuffer, bufferToBase64Url } from '@/utils/util';
interface DrawerParams {
sslStatus: string;
supported: boolean;
}
const drawerVisible = ref(false);
const passkeyLoading = ref(false);
const passkeyList = ref<Setting.PasskeyInfo[]>([]);
const passkeyForm = reactive({ name: '' });
const passkeySupported = ref(false);
const sslStatus = ref('Disable');
const passkeyMaxCount = 5;
const passkeyCountText = computed(() => {
return i18n.global.t('setting.passkeyCount', [passkeyList.value.length, passkeyMaxCount]);
});
const canRegisterPasskey = computed(() => {
return (
sslStatus.value !== 'Disable' &&
passkeySupported.value &&
passkeyList.value.length < passkeyMaxCount &&
passkeyForm.name.trim().length > 0
);
});
const acceptParams = async (params: DrawerParams) => {
sslStatus.value = params.sslStatus;
passkeySupported.value = params.supported;
drawerVisible.value = true;
await loadPasskeys();
};
const loadPasskeys = async () => {
passkeyLoading.value = true;
try {
const res = await fetchPasskeyList();
passkeyList.value = res.data || [];
} catch (error) {
passkeyList.value = [];
} finally {
passkeyLoading.value = false;
}
};
const registerPasskey = async () => {
if (sslStatus.value === 'Disable') {
MsgError(i18n.global.t('setting.passkeyRequireSSL'));
return;
}
if (!passkeySupported.value) {
MsgError(i18n.global.t('setting.passkeyNotSupported'));
return;
}
if (passkeyList.value.length >= passkeyMaxCount) {
MsgError(i18n.global.t('setting.passkeyLimit'));
return;
}
if (!passkeyForm.name.trim()) {
MsgError(i18n.global.t('commons.rule.requiredInput'));
return;
}
passkeyLoading.value = true;
try {
const res = await passkeyRegisterBegin({ name: passkeyForm.name.trim() });
const publicKey = normalizePasskeyCreation(res.data.publicKey);
const credential = (await navigator.credentials.create({ publicKey })) as PublicKeyCredential | null;
if (!credential) {
MsgError(i18n.global.t('setting.passkeyFailed'));
return;
}
const payload = buildPasskeyAttestation(credential);
await passkeyRegisterFinish(payload, res.data.sessionId);
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
passkeyForm.name = '';
await loadPasskeys();
} catch (res: any) {
if (res?.message) {
console.log(res.message);
}
} finally {
passkeyLoading.value = false;
}
};
const removePasskey = async (id: string) => {
ElMessageBox.confirm(i18n.global.t('setting.passkeyDeleteConfirm'), i18n.global.t('setting.passkey'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
})
.then(async () => {
passkeyLoading.value = true;
await passkeyDelete(id);
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
await loadPasskeys();
})
.catch(() => {})
.finally(() => {
passkeyLoading.value = false;
});
};
const normalizePasskeyCreation = (publicKey: Record<string, any>): PublicKeyCredentialCreationOptions => {
const request = { ...publicKey };
request.challenge = base64UrlToBuffer(request.challenge);
request.user = { ...request.user, id: base64UrlToBuffer(request.user.id) };
if (request.excludeCredentials && Array.isArray(request.excludeCredentials)) {
request.excludeCredentials = request.excludeCredentials.map((item) => {
return { ...item, id: base64UrlToBuffer(item.id) };
});
}
return request as PublicKeyCredentialCreationOptions;
};
const buildPasskeyAttestation = (credential: PublicKeyCredential) => {
const response = credential.response as AuthenticatorAttestationResponse;
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
attestationObject: bufferToBase64Url(response.attestationObject),
},
clientExtensionResults: credential.getClientExtensionResults(),
authenticatorAttachment: credential.authenticatorAttachment,
};
};
const handleClose = () => {
drawerVisible.value = false;
};
defineExpose({
acceptParams,
});
</script>