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
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.pnpm-store
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
+6
-1
@@ -142,6 +142,11 @@
|
||||
"patchedDependencies": {
|
||||
"slate-react": "patches/slate-react.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",
|
||||
"password": "Sign in with Password",
|
||||
"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?"
|
||||
},
|
||||
"reg": {
|
||||
|
||||
@@ -285,11 +285,32 @@
|
||||
"github_desc": "Allows members login with GitHub.",
|
||||
"metamask": "Metamask",
|
||||
"metamask_desc": "Allows members login with Metamask.",
|
||||
"passkey": "Passkey",
|
||||
"passkey_desc": "Allows members login with Passkey.",
|
||||
"oidc": "OIDC",
|
||||
"oidc_desc": "Allows members login with OIDC.",
|
||||
"oidc_custom": "Custom",
|
||||
"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": {
|
||||
"tip": "Extending VoceChat by embedding the vocechat widget SDK!",
|
||||
"code": "Code Example",
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
"metamask": "MetaMask登录",
|
||||
"password": "账号密码登录",
|
||||
"oidc": "OIDC登录",
|
||||
"passkey": "使用通行密钥登录",
|
||||
"passkey_authenticating": "认证中...",
|
||||
"passkey_error_not_supported": "您的浏览器不支持通行密钥",
|
||||
"passkey_error_cancelled": "用户取消或超时",
|
||||
"passkey_error_no_passkey": "未找到此账号的通行密钥",
|
||||
"passkey_error_failed": "通行密钥登录失败",
|
||||
"no_account": "没有账号?"
|
||||
},
|
||||
"reg": {
|
||||
|
||||
@@ -285,11 +285,32 @@
|
||||
"github_desc": "允许用户使用 GitHub 登录",
|
||||
"metamask": "Metamask",
|
||||
"metamask_desc": "允许用户使用 Metamask 登录",
|
||||
"passkey": "通行密钥",
|
||||
"passkey_desc": "允许用户使用通行密钥登录",
|
||||
"oidc": "OIDC",
|
||||
"oidc_desc": "允许用户使用 OIDC 登录",
|
||||
"oidc_custom": "自定义",
|
||||
"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": {
|
||||
"tip": "将挂件 SDK 内嵌到网页,实现 VoceChat 扩展!",
|
||||
"code": "代码举例",
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ const prices: Price[] = [
|
||||
},
|
||||
];
|
||||
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 dev_origin = process.env.REACT_APP_OFFICIAL_DEMO ? official_dev : local_dev;
|
||||
|
||||
|
||||
@@ -212,6 +212,87 @@ export const authApi = createApi({
|
||||
url: `/user/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,
|
||||
useUpdatePasswordMutation,
|
||||
useRegisterMutation,
|
||||
useLazyDeleteCurrentAccountQuery
|
||||
useLazyDeleteCurrentAccountQuery,
|
||||
usePasskeyRegisterStartMutation,
|
||||
usePasskeyRegisterFinishMutation,
|
||||
usePasskeyLoginStartMutation,
|
||||
usePasskeyLoginFinishMutation,
|
||||
useGetUserPasskeysQuery,
|
||||
useLazyGetUserPasskeysQuery,
|
||||
useDeletePasskeyMutation
|
||||
} = authApi;
|
||||
|
||||
@@ -29,6 +29,8 @@ const whiteList = [
|
||||
"sendMessageByBot",
|
||||
"getAgoraVoicingList",
|
||||
"preCheckFileFromUrl",
|
||||
"passkeyLoginStart",
|
||||
"passkeyLoginFinish",
|
||||
];
|
||||
const whiteList401 = ["getAgoraVoicingList", "getAgoraChannels"];
|
||||
const errorWhiteList = [
|
||||
@@ -141,6 +143,11 @@ const baseQueryWithTokenCheck = async (args: any, api: any, extraOptions: any) =
|
||||
toast.error("File size too large");
|
||||
}
|
||||
break;
|
||||
case 415:
|
||||
{
|
||||
toast.error("Unsupported Media Type");
|
||||
}
|
||||
break;
|
||||
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 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 { useAppSelector } from "@/app/store";
|
||||
import Divider from "@/components/Divider";
|
||||
@@ -18,6 +18,8 @@ import SocialLoginButtons from "./SocialLoginButtons";
|
||||
import { shallowEqual } from "react-redux";
|
||||
import SelectLanguage from "../../components/Language";
|
||||
import Downloads from "../../components/Downloads";
|
||||
import { startPasskeyLogin, isWebAuthnSupported } from "@/passkey";
|
||||
import ServerVersionChecker from "@/components/ServerVersionChecker";
|
||||
|
||||
const defaultInput = {
|
||||
email: "",
|
||||
@@ -32,6 +34,9 @@ export default function LoginPage() {
|
||||
const { data: loginConfig, isSuccess: loginConfigSuccess } = useGetLoginConfigQuery();
|
||||
const [emailInputted, setEmailInputted] = useState(false);
|
||||
const [input, setInput] = useState(defaultInput);
|
||||
const [passkeyLoginStart] = usePasskeyLoginStartMutation();
|
||||
const [passkeyLoginFinish] = usePasskeyLoginFinishMutation();
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const query = new URLSearchParams(location.search);
|
||||
@@ -122,10 +127,40 @@ export default function LoginPage() {
|
||||
const handleBack = () => {
|
||||
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;
|
||||
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 hideSocials = (enableMagicLink && emailInputted) || whoCanSignUp == "InvitationOnly";
|
||||
@@ -198,6 +233,17 @@ export default function LoginPage() {
|
||||
</form>
|
||||
<Divider content="OR" />
|
||||
<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} />}
|
||||
{!hideSocials && <SocialLoginButtons />}
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,16 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useUpdateAvatarMutation } from "@/app/services/user";
|
||||
import { useGetLoginConfigQuery } from "@/app/services/server";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import AvatarUploader from "@/components/AvatarUploader";
|
||||
import Button from "@/components/styled/Button";
|
||||
import ProfileBasicEditModal from "./ProfileBasicEditModal";
|
||||
import RemoveAccountConfirmModal from "./RemoveAccountConfirmModal";
|
||||
import UpdatePasswordModal from "./UpdatePasswordModal";
|
||||
import PasskeyManagement from "./PasskeyManagement";
|
||||
import { shallowEqual } from "react-redux";
|
||||
import ServerVersionChecker from "@/components/ServerVersionChecker";
|
||||
|
||||
type EditField = "name" | "email" | "";
|
||||
export default function MyAccount() {
|
||||
@@ -19,6 +22,7 @@ export default function MyAccount() {
|
||||
const [editModal, setEditModal] = useState<EditField>("");
|
||||
const [removeConfirmVisible, setRemoveConfirmVisible] = useState(false);
|
||||
const [uploadAvatar, { isSuccess: uploadSuccess }] = useUpdateAvatarMutation();
|
||||
const { data: loginConfig } = useGetLoginConfigQuery();
|
||||
const EditModalInfo = {
|
||||
name: {
|
||||
label: t("username"),
|
||||
@@ -95,6 +99,15 @@ export default function MyAccount() {
|
||||
<Button onClick={togglePasswordModal}>{ct("action.edit")}</Button>
|
||||
</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 && (
|
||||
<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 IssuerList from "./IssuerList";
|
||||
import Tooltip from "./Tooltip";
|
||||
import ServerVersionChecker from "@/components/ServerVersionChecker";
|
||||
|
||||
export default function Logins() {
|
||||
const { t } = useTranslation("setting", { keyPrefix: "login" });
|
||||
@@ -59,7 +60,7 @@ export default function Logins() {
|
||||
}
|
||||
};
|
||||
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) => {
|
||||
if (!prev) return prev;
|
||||
@@ -67,7 +68,7 @@ export default function Logins() {
|
||||
});
|
||||
};
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -170,6 +171,22 @@ export default function Logins() {
|
||||
></Toggle>
|
||||
</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="row">
|
||||
<div className="title">
|
||||
|
||||
+61
-1
@@ -53,6 +53,11 @@ export type ThirdPartyCredential = {
|
||||
key: string;
|
||||
type: "thirdparty";
|
||||
};
|
||||
export type PasskeyCredential = {
|
||||
challenge_id: string;
|
||||
credential: PublicKeyCredentialWithResponse;
|
||||
type: "passkey";
|
||||
};
|
||||
export type LoginCredential =
|
||||
| PasswordCredential
|
||||
| OIDCCredential
|
||||
@@ -60,7 +65,8 @@ export type LoginCredential =
|
||||
| GithubCredential
|
||||
| GoogleCredential
|
||||
| ThirdPartyCredential
|
||||
| MagicLinkCredential;
|
||||
| MagicLinkCredential
|
||||
| PasskeyCredential;
|
||||
|
||||
export type CredentialResponse = {
|
||||
password: boolean;
|
||||
@@ -73,3 +79,57 @@ export interface OIDCConfig {
|
||||
favicon: 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[];
|
||||
metamask: boolean;
|
||||
third_party: boolean;
|
||||
passkey?: boolean;
|
||||
}
|
||||
export interface LicenseResponse {
|
||||
domains: string[];
|
||||
|
||||
Reference in New Issue
Block a user