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:
haorwen
2025-12-12 21:25:49 +08:00
committed by GitHub
parent 344ad200df
commit 210a34b33c
16 changed files with 578 additions and 8 deletions
+1
View File
@@ -4,6 +4,7 @@
/node_modules /node_modules
/.pnp /.pnp
.pnp.js .pnp.js
.pnpm-store
# testing # testing
/coverage /coverage
+6 -1
View File
@@ -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
View File
@@ -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": {
+21
View File
@@ -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
View File
@@ -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": {
+21
View File
@@ -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
View File
@@ -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;
+89 -1
View File
@@ -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;
+7
View File
@@ -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
View File
@@ -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
};
}
+48 -2
View File
@@ -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>
+13
View File
@@ -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}>
+166
View File
@@ -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>
);
}
+19 -2
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -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[];