feat: 增加passkey支持 (#270)
* chore: add .pnpm-store to .gitignore * feat: implement passkey authentication and management features - Added passkey login and registration endpoints to the auth service. - Introduced PasskeyManagement component for user passkey management. - Updated localization files to include passkey-related strings in English and Chinese. - Created utility functions for handling passkey operations in WebAuthn. - Adjusted login page to support passkey login flow. - Modified configuration for local development server. * feat: enhance passkey management with modal input and localization updates - Added a modal for entering passkey names during registration in the PasskeyManagement component. - Updated English and Chinese localization files to include new strings for passkey name input. - Improved user experience by validating passkey name input before registration. * feat: enhance passkey login experience with localization updates - Added new localization strings for passkey authentication errors and status messages in English and Chinese. - Updated the login page to utilize localized strings for improved user feedback during passkey login attempts. * feat: add name field to UserPasskey interface for enhanced user identification * feat: integrate passkey support across login and settings - Added passkey option to login configuration and updated related components to conditionally render passkey login button. - Enhanced MyAccount and Logins components to manage passkey settings and display passkey management options based on configuration. - Updated English and Chinese localization files to include new strings for passkey functionality. * feat: added server version check for passkey
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|||||||
+6
-1
@@ -142,6 +142,11 @@
|
|||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"slate-react": "patches/slate-react.patch",
|
"slate-react": "patches/slate-react.patch",
|
||||||
"@toast-ui/editor": "patches/@toast-ui__editor.patch"
|
"@toast-ui/editor": "patches/@toast-ui__editor.patch"
|
||||||
}
|
},
|
||||||
|
"ignoredBuiltDependencies": [
|
||||||
|
"@firebase/util",
|
||||||
|
"core-js-pure",
|
||||||
|
"protobufjs"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
"metamask": "Sign in with MetaMask",
|
"metamask": "Sign in with MetaMask",
|
||||||
"password": "Sign in with Password",
|
"password": "Sign in with Password",
|
||||||
"oidc": "Sign in with OIDC",
|
"oidc": "Sign in with OIDC",
|
||||||
|
"passkey": "Sign in with Passkey",
|
||||||
|
"passkey_authenticating": "Authenticating...",
|
||||||
|
"passkey_error_not_supported": "Your browser doesn't support Passkey",
|
||||||
|
"passkey_error_cancelled": "User cancelled or timeout",
|
||||||
|
"passkey_error_no_passkey": "No passkey found for this account",
|
||||||
|
"passkey_error_failed": "Failed to login with passkey",
|
||||||
"no_account": "Don't have an account?"
|
"no_account": "Don't have an account?"
|
||||||
},
|
},
|
||||||
"reg": {
|
"reg": {
|
||||||
|
|||||||
@@ -285,11 +285,32 @@
|
|||||||
"github_desc": "Allows members login with GitHub.",
|
"github_desc": "Allows members login with GitHub.",
|
||||||
"metamask": "Metamask",
|
"metamask": "Metamask",
|
||||||
"metamask_desc": "Allows members login with Metamask.",
|
"metamask_desc": "Allows members login with Metamask.",
|
||||||
|
"passkey": "Passkey",
|
||||||
|
"passkey_desc": "Allows members login with Passkey.",
|
||||||
"oidc": "OIDC",
|
"oidc": "OIDC",
|
||||||
"oidc_desc": "Allows members login with OIDC.",
|
"oidc_desc": "Allows members login with OIDC.",
|
||||||
"oidc_custom": "Custom",
|
"oidc_custom": "Custom",
|
||||||
"more_details": "Need more detail? See our <0>doc</0>."
|
"more_details": "Need more detail? See our <0>doc</0>."
|
||||||
},
|
},
|
||||||
|
"passkey": {
|
||||||
|
"title": "Passkeys",
|
||||||
|
"desc": "Manage your passkeys for passwordless login",
|
||||||
|
"add": "Add Passkey",
|
||||||
|
"adding": "Registering...",
|
||||||
|
"delete": "Delete",
|
||||||
|
"delete_confirm": "Are you sure you want to delete this passkey?",
|
||||||
|
"no_passkeys": "No passkeys registered yet",
|
||||||
|
"created": "Created",
|
||||||
|
"last_used": "Last used",
|
||||||
|
"success_add": "Passkey registered successfully",
|
||||||
|
"success_delete": "Passkey deleted successfully",
|
||||||
|
"error_add": "Failed to register passkey",
|
||||||
|
"error_delete": "Failed to delete passkey",
|
||||||
|
"error_not_supported": "Your browser doesn't support Passkey",
|
||||||
|
"error_cancelled": "User cancelled or timeout",
|
||||||
|
"error_exists": "Passkey already exists",
|
||||||
|
"enter_name": "Enter a name for this passkey"
|
||||||
|
},
|
||||||
"widget": {
|
"widget": {
|
||||||
"tip": "Extending VoceChat by embedding the vocechat widget SDK!",
|
"tip": "Extending VoceChat by embedding the vocechat widget SDK!",
|
||||||
"code": "Code Example",
|
"code": "Code Example",
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
"metamask": "MetaMask登录",
|
"metamask": "MetaMask登录",
|
||||||
"password": "账号密码登录",
|
"password": "账号密码登录",
|
||||||
"oidc": "OIDC登录",
|
"oidc": "OIDC登录",
|
||||||
|
"passkey": "使用通行密钥登录",
|
||||||
|
"passkey_authenticating": "认证中...",
|
||||||
|
"passkey_error_not_supported": "您的浏览器不支持通行密钥",
|
||||||
|
"passkey_error_cancelled": "用户取消或超时",
|
||||||
|
"passkey_error_no_passkey": "未找到此账号的通行密钥",
|
||||||
|
"passkey_error_failed": "通行密钥登录失败",
|
||||||
"no_account": "没有账号?"
|
"no_account": "没有账号?"
|
||||||
},
|
},
|
||||||
"reg": {
|
"reg": {
|
||||||
|
|||||||
@@ -285,11 +285,32 @@
|
|||||||
"github_desc": "允许用户使用 GitHub 登录",
|
"github_desc": "允许用户使用 GitHub 登录",
|
||||||
"metamask": "Metamask",
|
"metamask": "Metamask",
|
||||||
"metamask_desc": "允许用户使用 Metamask 登录",
|
"metamask_desc": "允许用户使用 Metamask 登录",
|
||||||
|
"passkey": "通行密钥",
|
||||||
|
"passkey_desc": "允许用户使用通行密钥登录",
|
||||||
"oidc": "OIDC",
|
"oidc": "OIDC",
|
||||||
"oidc_desc": "允许用户使用 OIDC 登录",
|
"oidc_desc": "允许用户使用 OIDC 登录",
|
||||||
"oidc_custom": "自定义",
|
"oidc_custom": "自定义",
|
||||||
"more_details": "更多详情,移步 <0> 参考文档 </0> ."
|
"more_details": "更多详情,移步 <0> 参考文档 </0> ."
|
||||||
},
|
},
|
||||||
|
"passkey": {
|
||||||
|
"title": "通行密钥",
|
||||||
|
"desc": "管理您的通行密钥以实现无密码登录",
|
||||||
|
"add": "添加通行密钥",
|
||||||
|
"adding": "注册中...",
|
||||||
|
"delete": "删除",
|
||||||
|
"delete_confirm": "确定要删除此通行密钥吗?",
|
||||||
|
"no_passkeys": "尚未注册通行密钥",
|
||||||
|
"created": "创建时间",
|
||||||
|
"last_used": "上次使用",
|
||||||
|
"success_add": "通行密钥注册成功",
|
||||||
|
"success_delete": "通行密钥删除成功",
|
||||||
|
"error_add": "通行密钥注册失败",
|
||||||
|
"error_delete": "通行密钥删除失败",
|
||||||
|
"error_not_supported": "您的浏览器不支持通行密钥",
|
||||||
|
"error_cancelled": "用户取消或超时",
|
||||||
|
"error_exists": "通行密钥已存在",
|
||||||
|
"enter_name": "请输入通行密钥名称"
|
||||||
|
},
|
||||||
"widget": {
|
"widget": {
|
||||||
"tip": "将挂件 SDK 内嵌到网页,实现 VoceChat 扩展!",
|
"tip": "将挂件 SDK 内嵌到网页,实现 VoceChat 扩展!",
|
||||||
"code": "代码举例",
|
"code": "代码举例",
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ const prices: Price[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
const official_dev = `https://dev.voce.chat`;
|
const official_dev = `https://dev.voce.chat`;
|
||||||
const local_dev = `http://localhost:3333`;
|
const local_dev = `http://localhost:3000`;
|
||||||
// const local_dev = `https://dev.voce.chat`;
|
// const local_dev = `https://dev.voce.chat`;
|
||||||
const dev_origin = process.env.REACT_APP_OFFICIAL_DEMO ? official_dev : local_dev;
|
const dev_origin = process.env.REACT_APP_OFFICIAL_DEMO ? official_dev : local_dev;
|
||||||
|
|
||||||
|
|||||||
@@ -212,6 +212,87 @@ export const authApi = createApi({
|
|||||||
url: `/user/delete`,
|
url: `/user/delete`,
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
})
|
})
|
||||||
|
}),
|
||||||
|
// Passkey endpoints
|
||||||
|
passkeyRegisterStart: builder.mutation<
|
||||||
|
import("@/types/auth").PasskeyRegisterStartResponse,
|
||||||
|
import("@/types/auth").PasskeyRegisterStartRequest
|
||||||
|
>({
|
||||||
|
query: (data) => ({
|
||||||
|
url: "token/passkey/register/start",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
passkeyRegisterFinish: builder.mutation<
|
||||||
|
{ success: boolean; message: string },
|
||||||
|
import("@/types/auth").PasskeyRegisterFinishRequest
|
||||||
|
>({
|
||||||
|
query: (data) => ({
|
||||||
|
url: "token/passkey/register/finish",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
passkeyLoginStart: builder.mutation<
|
||||||
|
import("@/types/auth").PasskeyLoginStartResponse,
|
||||||
|
import("@/types/auth").PasskeyLoginStartRequest
|
||||||
|
>({
|
||||||
|
query: (data) => ({
|
||||||
|
url: "token/passkey/auth/start",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
passkeyLoginFinish: builder.mutation<AuthData, import("@/types/auth").PasskeyLoginFinishRequest>({
|
||||||
|
query: (data) => ({
|
||||||
|
url: "token/passkey/auth/finish",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: data
|
||||||
|
}),
|
||||||
|
transformResponse: (data: AuthData) => {
|
||||||
|
const { avatar_updated_at } = data.user;
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
avatar:
|
||||||
|
avatar_updated_at == 0
|
||||||
|
? ""
|
||||||
|
: `${BASE_URL}/resource/avatar?uid=${data.user.uid}&t=${avatar_updated_at}`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async onQueryStarted(params, { dispatch, queryFulfilled }) {
|
||||||
|
try {
|
||||||
|
const { data } = await queryFulfilled;
|
||||||
|
if (data) {
|
||||||
|
dispatch(setAuthData(data));
|
||||||
|
dispatch(setReady(false));
|
||||||
|
dispatch(updateSSEStatus("disconnected"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log("passkey login error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
getUserPasskeys: builder.query<import("@/types/auth").UserPasskey[], void>({
|
||||||
|
query: () => ({ url: "token/passkey/list" })
|
||||||
|
}),
|
||||||
|
deletePasskey: builder.mutation<void, string>({
|
||||||
|
query: (credentialId) => ({
|
||||||
|
url: `token/passkey/${encodeURIComponent(credentialId)}`,
|
||||||
|
method: "DELETE"
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -234,5 +315,12 @@ export const {
|
|||||||
useCheckMagicTokenValidMutation,
|
useCheckMagicTokenValidMutation,
|
||||||
useUpdatePasswordMutation,
|
useUpdatePasswordMutation,
|
||||||
useRegisterMutation,
|
useRegisterMutation,
|
||||||
useLazyDeleteCurrentAccountQuery
|
useLazyDeleteCurrentAccountQuery,
|
||||||
|
usePasskeyRegisterStartMutation,
|
||||||
|
usePasskeyRegisterFinishMutation,
|
||||||
|
usePasskeyLoginStartMutation,
|
||||||
|
usePasskeyLoginFinishMutation,
|
||||||
|
useGetUserPasskeysQuery,
|
||||||
|
useLazyGetUserPasskeysQuery,
|
||||||
|
useDeletePasskeyMutation
|
||||||
} = authApi;
|
} = authApi;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const whiteList = [
|
|||||||
"sendMessageByBot",
|
"sendMessageByBot",
|
||||||
"getAgoraVoicingList",
|
"getAgoraVoicingList",
|
||||||
"preCheckFileFromUrl",
|
"preCheckFileFromUrl",
|
||||||
|
"passkeyLoginStart",
|
||||||
|
"passkeyLoginFinish",
|
||||||
];
|
];
|
||||||
const whiteList401 = ["getAgoraVoicingList", "getAgoraChannels"];
|
const whiteList401 = ["getAgoraVoicingList", "getAgoraChannels"];
|
||||||
const errorWhiteList = [
|
const errorWhiteList = [
|
||||||
@@ -141,6 +143,11 @@ const baseQueryWithTokenCheck = async (args: any, api: any, extraOptions: any) =
|
|||||||
toast.error("File size too large");
|
toast.error("File size too large");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 415:
|
||||||
|
{
|
||||||
|
toast.error("Unsupported Media Type");
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 451:
|
case 451:
|
||||||
{
|
{
|
||||||
// 证书错误
|
// 证书错误
|
||||||
|
|||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
// Passkey utility functions for WebAuthn
|
||||||
|
|
||||||
|
// Base64 转 ArrayBuffer
|
||||||
|
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||||
|
const binaryString = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArrayBuffer 转 Base64
|
||||||
|
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查浏览器是否支持 WebAuthn
|
||||||
|
export function isWebAuthnSupported(): boolean {
|
||||||
|
return window.PublicKeyCredential !== undefined && navigator.credentials !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有平台认证器可用
|
||||||
|
export async function isPlatformAuthenticatorAvailable(): Promise<boolean> {
|
||||||
|
if (!isWebAuthnSupported()) return false;
|
||||||
|
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始 Passkey 注册流程
|
||||||
|
export async function startPasskeyRegistration(options: any) {
|
||||||
|
if (!isWebAuthnSupported()) {
|
||||||
|
throw new Error('WebAuthn not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保创建 Discoverable Credential
|
||||||
|
const authenticatorSelection = {
|
||||||
|
...(options.publicKey.authenticatorSelection || {}),
|
||||||
|
residentKey: "required" as ResidentKeyRequirement,
|
||||||
|
requireResidentKey: true,
|
||||||
|
userVerification: options.publicKey.authenticatorSelection?.userVerification || "preferred" as UserVerificationRequirement
|
||||||
|
};
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.create({
|
||||||
|
publicKey: {
|
||||||
|
...options.publicKey,
|
||||||
|
challenge: base64ToArrayBuffer(options.publicKey.challenge),
|
||||||
|
user: {
|
||||||
|
...options.publicKey.user,
|
||||||
|
id: base64ToArrayBuffer(options.publicKey.user.id)
|
||||||
|
},
|
||||||
|
authenticatorSelection
|
||||||
|
}
|
||||||
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
throw new Error('Failed to create credential');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = credential.response as AuthenticatorAttestationResponse;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: arrayBufferToBase64(credential.rawId),
|
||||||
|
response: {
|
||||||
|
clientDataJSON: arrayBufferToBase64(response.clientDataJSON),
|
||||||
|
attestationObject: arrayBufferToBase64(response.attestationObject)
|
||||||
|
},
|
||||||
|
type: credential.type
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始 Passkey 登录流程
|
||||||
|
export async function startPasskeyLogin(options: any) {
|
||||||
|
if (!isWebAuthnSupported()) {
|
||||||
|
throw new Error('WebAuthn not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: {
|
||||||
|
...options.publicKey,
|
||||||
|
challenge: base64ToArrayBuffer(options.publicKey.challenge),
|
||||||
|
allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
|
||||||
|
...cred,
|
||||||
|
id: base64ToArrayBuffer(cred.id)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
throw new Error('Failed to get credential');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = credential.response as AuthenticatorAssertionResponse;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: arrayBufferToBase64(credential.rawId),
|
||||||
|
response: {
|
||||||
|
clientDataJSON: arrayBufferToBase64(response.clientDataJSON),
|
||||||
|
authenticatorData: arrayBufferToBase64(response.authenticatorData),
|
||||||
|
signature: arrayBufferToBase64(response.signature),
|
||||||
|
userHandle: response.userHandle ? arrayBufferToBase64(response.userHandle) : null
|
||||||
|
},
|
||||||
|
type: credential.type
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
|
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
|
||||||
|
|
||||||
import BASE_URL from "@/app/config";
|
import BASE_URL from "@/app/config";
|
||||||
import { useLoginMutation } from "@/app/services/auth";
|
import { useLoginMutation, usePasskeyLoginStartMutation, usePasskeyLoginFinishMutation } from "@/app/services/auth";
|
||||||
import { useGetLoginConfigQuery, useGetSMTPStatusQuery } from "@/app/services/server";
|
import { useGetLoginConfigQuery, useGetSMTPStatusQuery } from "@/app/services/server";
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
import Divider from "@/components/Divider";
|
import Divider from "@/components/Divider";
|
||||||
@@ -18,6 +18,8 @@ import SocialLoginButtons from "./SocialLoginButtons";
|
|||||||
import { shallowEqual } from "react-redux";
|
import { shallowEqual } from "react-redux";
|
||||||
import SelectLanguage from "../../components/Language";
|
import SelectLanguage from "../../components/Language";
|
||||||
import Downloads from "../../components/Downloads";
|
import Downloads from "../../components/Downloads";
|
||||||
|
import { startPasskeyLogin, isWebAuthnSupported } from "@/passkey";
|
||||||
|
import ServerVersionChecker from "@/components/ServerVersionChecker";
|
||||||
|
|
||||||
const defaultInput = {
|
const defaultInput = {
|
||||||
email: "",
|
email: "",
|
||||||
@@ -32,6 +34,9 @@ export default function LoginPage() {
|
|||||||
const { data: loginConfig, isSuccess: loginConfigSuccess } = useGetLoginConfigQuery();
|
const { data: loginConfig, isSuccess: loginConfigSuccess } = useGetLoginConfigQuery();
|
||||||
const [emailInputted, setEmailInputted] = useState(false);
|
const [emailInputted, setEmailInputted] = useState(false);
|
||||||
const [input, setInput] = useState(defaultInput);
|
const [input, setInput] = useState(defaultInput);
|
||||||
|
const [passkeyLoginStart] = usePasskeyLoginStartMutation();
|
||||||
|
const [passkeyLoginFinish] = usePasskeyLoginFinishMutation();
|
||||||
|
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const query = new URLSearchParams(location.search);
|
const query = new URLSearchParams(location.search);
|
||||||
@@ -122,10 +127,40 @@ export default function LoginPage() {
|
|||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
setEmailInputted(false);
|
setEmailInputted(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePasskeyLogin = async () => {
|
||||||
|
if (!isWebAuthnSupported()) {
|
||||||
|
toast.error(t("login.passkey_error_not_supported"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPasskeyLoading(true);
|
||||||
|
try {
|
||||||
|
const { challenge_id, options } = await passkeyLoginStart({}).unwrap();
|
||||||
|
|
||||||
|
const credential = await startPasskeyLogin(options);
|
||||||
|
|
||||||
|
await passkeyLoginFinish({ challenge_id, authentication: credential }).unwrap();
|
||||||
|
|
||||||
|
toast.success(ct("tip.login"));
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
toast.error(t("login.passkey_error_cancelled"));
|
||||||
|
} else if (error.status === 404) {
|
||||||
|
toast.error(t("login.passkey_error_no_passkey"));
|
||||||
|
} else {
|
||||||
|
toast.error(t("login.passkey_error_failed"));
|
||||||
|
}
|
||||||
|
console.error("Passkey login error:", error);
|
||||||
|
} finally {
|
||||||
|
setIsPasskeyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const { email, password } = input;
|
const { email, password } = input;
|
||||||
if (!loginConfigSuccess) return null;
|
if (!loginConfigSuccess) return null;
|
||||||
|
|
||||||
const { magic_link, who_can_sign_up: whoCanSignUp } = loginConfig;
|
const { magic_link, who_can_sign_up: whoCanSignUp, passkey } = loginConfig;
|
||||||
|
|
||||||
const enableMagicLink = enableSMTP && magic_link;
|
const enableMagicLink = enableSMTP && magic_link;
|
||||||
const hideSocials = (enableMagicLink && emailInputted) || whoCanSignUp == "InvitationOnly";
|
const hideSocials = (enableMagicLink && emailInputted) || whoCanSignUp == "InvitationOnly";
|
||||||
@@ -198,6 +233,17 @@ export default function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
<Divider content="OR" />
|
<Divider content="OR" />
|
||||||
<div className="socials flex flex-col gap-3">
|
<div className="socials flex flex-col gap-3">
|
||||||
|
{passkey && (
|
||||||
|
<ServerVersionChecker empty version="0.5.5">
|
||||||
|
<Button
|
||||||
|
onClick={handlePasskeyLogin}
|
||||||
|
disabled={isPasskeyLoading || isLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isPasskeyLoading ? t("login.passkey_authenticating") : t("login.passkey")}
|
||||||
|
</Button>
|
||||||
|
</ServerVersionChecker>
|
||||||
|
)}
|
||||||
{emailInputted && <MagicLinkLogin email={input.email} />}
|
{emailInputted && <MagicLinkLogin email={input.email} />}
|
||||||
{!hideSocials && <SocialLoginButtons />}
|
{!hideSocials && <SocialLoginButtons />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import toast from "react-hot-toast";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useUpdateAvatarMutation } from "@/app/services/user";
|
import { useUpdateAvatarMutation } from "@/app/services/user";
|
||||||
|
import { useGetLoginConfigQuery } from "@/app/services/server";
|
||||||
import { useAppSelector } from "@/app/store";
|
import { useAppSelector } from "@/app/store";
|
||||||
import AvatarUploader from "@/components/AvatarUploader";
|
import AvatarUploader from "@/components/AvatarUploader";
|
||||||
import Button from "@/components/styled/Button";
|
import Button from "@/components/styled/Button";
|
||||||
import ProfileBasicEditModal from "./ProfileBasicEditModal";
|
import ProfileBasicEditModal from "./ProfileBasicEditModal";
|
||||||
import RemoveAccountConfirmModal from "./RemoveAccountConfirmModal";
|
import RemoveAccountConfirmModal from "./RemoveAccountConfirmModal";
|
||||||
import UpdatePasswordModal from "./UpdatePasswordModal";
|
import UpdatePasswordModal from "./UpdatePasswordModal";
|
||||||
|
import PasskeyManagement from "./PasskeyManagement";
|
||||||
import { shallowEqual } from "react-redux";
|
import { shallowEqual } from "react-redux";
|
||||||
|
import ServerVersionChecker from "@/components/ServerVersionChecker";
|
||||||
|
|
||||||
type EditField = "name" | "email" | "";
|
type EditField = "name" | "email" | "";
|
||||||
export default function MyAccount() {
|
export default function MyAccount() {
|
||||||
@@ -19,6 +22,7 @@ export default function MyAccount() {
|
|||||||
const [editModal, setEditModal] = useState<EditField>("");
|
const [editModal, setEditModal] = useState<EditField>("");
|
||||||
const [removeConfirmVisible, setRemoveConfirmVisible] = useState(false);
|
const [removeConfirmVisible, setRemoveConfirmVisible] = useState(false);
|
||||||
const [uploadAvatar, { isSuccess: uploadSuccess }] = useUpdateAvatarMutation();
|
const [uploadAvatar, { isSuccess: uploadSuccess }] = useUpdateAvatarMutation();
|
||||||
|
const { data: loginConfig } = useGetLoginConfigQuery();
|
||||||
const EditModalInfo = {
|
const EditModalInfo = {
|
||||||
name: {
|
name: {
|
||||||
label: t("username"),
|
label: t("username"),
|
||||||
@@ -95,6 +99,15 @@ export default function MyAccount() {
|
|||||||
<Button onClick={togglePasswordModal}>{ct("action.edit")}</Button>
|
<Button onClick={togglePasswordModal}>{ct("action.edit")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loginConfig?.passkey && (
|
||||||
|
<ServerVersionChecker empty version="0.5.5">
|
||||||
|
<div className="w-full md:w-[512px] md:p-6 md:bg-gray-100 md:dark:bg-gray-800 md:rounded-2xl">
|
||||||
|
<PasskeyManagement />
|
||||||
|
</div>
|
||||||
|
</ServerVersionChecker>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* uid 1 是初始账户,不能删 */}
|
{/* uid 1 是初始账户,不能删 */}
|
||||||
{uid != 1 && (
|
{uid != 1 && (
|
||||||
<Button className="danger" onClick={toggleRemoveAccountModalVisible}>
|
<Button className="danger" onClick={toggleRemoveAccountModalVisible}>
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
useGetUserPasskeysQuery,
|
||||||
|
usePasskeyRegisterStartMutation,
|
||||||
|
usePasskeyRegisterFinishMutation,
|
||||||
|
useDeletePasskeyMutation
|
||||||
|
} from "@/app/services/auth";
|
||||||
|
import { startPasskeyRegistration, isWebAuthnSupported } from "@/passkey";
|
||||||
|
import Button from "@/components/styled/Button";
|
||||||
|
import Input from "@/components/styled/Input";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import StyledModal from "@/components/styled/Modal";
|
||||||
|
import { useAppSelector } from "@/app/store";
|
||||||
|
|
||||||
|
export default function PasskeyManagement() {
|
||||||
|
const { t } = useTranslation("setting");
|
||||||
|
const { t: ct } = useTranslation();
|
||||||
|
const [isRegistering, setIsRegistering] = useState(false);
|
||||||
|
const [passkeyName, setPasskeyName] = useState("");
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
const { data: passkeys, refetch } = useGetUserPasskeysQuery();
|
||||||
|
const [registerStart] = usePasskeyRegisterStartMutation();
|
||||||
|
const [registerFinish] = usePasskeyRegisterFinishMutation();
|
||||||
|
const [deletePasskey] = useDeletePasskeyMutation();
|
||||||
|
const user = useAppSelector((store) => store.authData.user);
|
||||||
|
|
||||||
|
const handleAddPasskey = async () => {
|
||||||
|
if (!isWebAuthnSupported()) {
|
||||||
|
toast.error(t("passkey.error_not_supported"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user?.name) {
|
||||||
|
toast.error("User information not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passkeyName.trim()) {
|
||||||
|
toast.error(t("passkey.enter_name") || "Please enter a name for this passkey");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRegistering(true);
|
||||||
|
setShowModal(false);
|
||||||
|
try {
|
||||||
|
const { challenge_id, options } = await registerStart({ name: user.name }).unwrap();
|
||||||
|
|
||||||
|
const credential = await startPasskeyRegistration(options);
|
||||||
|
|
||||||
|
await registerFinish({
|
||||||
|
challenge_id,
|
||||||
|
attestation: credential,
|
||||||
|
name: passkeyName.trim()
|
||||||
|
}).unwrap();
|
||||||
|
|
||||||
|
toast.success(t("passkey.success_add"));
|
||||||
|
setPasskeyName("");
|
||||||
|
refetch();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
toast.error(t("passkey.error_cancelled"));
|
||||||
|
} else if (error.name === 'InvalidStateError') {
|
||||||
|
toast.error(t("passkey.error_exists"));
|
||||||
|
} else {
|
||||||
|
toast.error(t("passkey.error_add"));
|
||||||
|
}
|
||||||
|
console.error("Passkey registration error:", error);
|
||||||
|
} finally {
|
||||||
|
setIsRegistering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePasskey = async (credentialId: string) => {
|
||||||
|
if (!confirm(t("passkey.delete_confirm"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deletePasskey(credentialId).unwrap();
|
||||||
|
toast.success(t("passkey.success_delete"));
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("passkey.error_delete"));
|
||||||
|
console.error("Passkey deletion error:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">{t("passkey.title")}</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t("passkey.desc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowModal(true)} disabled={isRegistering}>
|
||||||
|
{isRegistering ? t("passkey.adding") : t("passkey.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{passkeys && passkeys.length > 0 ? (
|
||||||
|
passkeys.map((passkey) => (
|
||||||
|
<div
|
||||||
|
key={passkey.credential_id}
|
||||||
|
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-gray-800 dark:text-white">
|
||||||
|
{passkey.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t("passkey.created")}: {new Date(passkey.created_at).toLocaleDateString()}
|
||||||
|
{passkey.last_used_at &&
|
||||||
|
` • ${t("passkey.last_used")}: ${new Date(passkey.last_used_at).toLocaleDateString()}`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="danger"
|
||||||
|
onClick={() => handleDeletePasskey(passkey.credential_id)}
|
||||||
|
>
|
||||||
|
{t("passkey.delete")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
{t("passkey.no_passkeys")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<Modal id="modal-modal">
|
||||||
|
<StyledModal
|
||||||
|
title={t("passkey.add")}
|
||||||
|
description={t("passkey.enter_name") || "Enter a name for this passkey"}
|
||||||
|
buttons={
|
||||||
|
<>
|
||||||
|
<Button className="cancel" onClick={() => { setShowModal(false); setPasskeyName(""); }}>
|
||||||
|
{ct("action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddPasskey} disabled={isRegistering}>
|
||||||
|
{isRegistering ? t("passkey.adding") : ct("action.done")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder={t("passkey.enter_name") || "Passkey name"}
|
||||||
|
value={passkeyName}
|
||||||
|
onChange={(e) => setPasskeyName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</StyledModal>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import useGithubAuthConfig from "@/hooks/useGithubAuthConfig";
|
|||||||
import useGoogleAuthConfig from "@/hooks/useGoogleAuthConfig";
|
import useGoogleAuthConfig from "@/hooks/useGoogleAuthConfig";
|
||||||
import IssuerList from "./IssuerList";
|
import IssuerList from "./IssuerList";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
import ServerVersionChecker from "@/components/ServerVersionChecker";
|
||||||
|
|
||||||
export default function Logins() {
|
export default function Logins() {
|
||||||
const { t } = useTranslation("setting", { keyPrefix: "login" });
|
const { t } = useTranslation("setting", { keyPrefix: "login" });
|
||||||
@@ -59,7 +60,7 @@ export default function Logins() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleToggle = (
|
const handleToggle = (
|
||||||
val: Partial<Pick<LoginConfig, "github" | "google" | "password" | "magic_link" | "metamask">>
|
val: Partial<Pick<LoginConfig, "github" | "google" | "password" | "magic_link" | "metamask" | "passkey">>
|
||||||
) => {
|
) => {
|
||||||
setValues((prev) => {
|
setValues((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
@@ -67,7 +68,7 @@ export default function Logins() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
if (!values) return null;
|
if (!values) return null;
|
||||||
const { google, magic_link, github, metamask, password, oidc = [] } = values as LoginConfig;
|
const { google, magic_link, github, metamask, password, passkey, oidc = [] } = values as LoginConfig;
|
||||||
const valuesChanged = clientIdChanged || changed || githubChanged;
|
const valuesChanged = clientIdChanged || changed || githubChanged;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -170,6 +171,22 @@ export default function Logins() {
|
|||||||
></Toggle>
|
></Toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ServerVersionChecker empty version="0.5.5">
|
||||||
|
<div className="input">
|
||||||
|
<div className="row">
|
||||||
|
<div className="title">
|
||||||
|
<div className="txt">
|
||||||
|
<Label>{t("passkey")}</Label>
|
||||||
|
</div>
|
||||||
|
<span className="desc dark:!text-gray-400">{t("passkey_desc")}</span>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
onClick={handleToggle.bind(null, { passkey: !passkey })}
|
||||||
|
checked={passkey}
|
||||||
|
></Toggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ServerVersionChecker>
|
||||||
<div className="input">
|
<div className="input">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="title">
|
<div className="title">
|
||||||
|
|||||||
+61
-1
@@ -53,6 +53,11 @@ export type ThirdPartyCredential = {
|
|||||||
key: string;
|
key: string;
|
||||||
type: "thirdparty";
|
type: "thirdparty";
|
||||||
};
|
};
|
||||||
|
export type PasskeyCredential = {
|
||||||
|
challenge_id: string;
|
||||||
|
credential: PublicKeyCredentialWithResponse;
|
||||||
|
type: "passkey";
|
||||||
|
};
|
||||||
export type LoginCredential =
|
export type LoginCredential =
|
||||||
| PasswordCredential
|
| PasswordCredential
|
||||||
| OIDCCredential
|
| OIDCCredential
|
||||||
@@ -60,7 +65,8 @@ export type LoginCredential =
|
|||||||
| GithubCredential
|
| GithubCredential
|
||||||
| GoogleCredential
|
| GoogleCredential
|
||||||
| ThirdPartyCredential
|
| ThirdPartyCredential
|
||||||
| MagicLinkCredential;
|
| MagicLinkCredential
|
||||||
|
| PasskeyCredential;
|
||||||
|
|
||||||
export type CredentialResponse = {
|
export type CredentialResponse = {
|
||||||
password: boolean;
|
password: boolean;
|
||||||
@@ -73,3 +79,57 @@ export interface OIDCConfig {
|
|||||||
favicon: string;
|
favicon: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Passkey types
|
||||||
|
export interface PublicKeyCredentialWithResponse {
|
||||||
|
id: string;
|
||||||
|
rawId: string;
|
||||||
|
response: {
|
||||||
|
clientDataJSON: string;
|
||||||
|
attestationObject?: string;
|
||||||
|
authenticatorData?: string;
|
||||||
|
signature?: string;
|
||||||
|
userHandle?: string | null;
|
||||||
|
};
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasskeyRegisterStartRequest {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasskeyRegisterStartResponse {
|
||||||
|
challenge_id: string;
|
||||||
|
options: {
|
||||||
|
publicKey: PublicKeyCredentialCreationOptions;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasskeyRegisterFinishRequest {
|
||||||
|
challenge_id: string;
|
||||||
|
credential: PublicKeyCredentialWithResponse;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasskeyLoginStartRequest {
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasskeyLoginStartResponse {
|
||||||
|
challenge_id: string;
|
||||||
|
options: {
|
||||||
|
publicKey: PublicKeyCredentialRequestOptions;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasskeyLoginFinishRequest {
|
||||||
|
challenge_id: string;
|
||||||
|
authentication: PublicKeyCredentialWithResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPasskey {
|
||||||
|
id: number;
|
||||||
|
credential_id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export interface LoginConfig {
|
|||||||
oidc: OIDCSetting[];
|
oidc: OIDCSetting[];
|
||||||
metamask: boolean;
|
metamask: boolean;
|
||||||
third_party: boolean;
|
third_party: boolean;
|
||||||
|
passkey?: boolean;
|
||||||
}
|
}
|
||||||
export interface LicenseResponse {
|
export interface LicenseResponse {
|
||||||
domains: string[];
|
domains: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user