mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
@@ -125,11 +125,10 @@ export const LabelText = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export interface Props
|
||||
extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||
"prefix"
|
||||
> {
|
||||
export interface Props extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||
"prefix"
|
||||
> {
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
|
||||
labelHidden?: boolean;
|
||||
label?: string;
|
||||
|
||||
@@ -6,11 +6,10 @@ import { LabelText } from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
interface Props
|
||||
extends Omit<
|
||||
React.ComponentProps<typeof RadixSwitch.Root>,
|
||||
"checked" | "onCheckedChange" | "onChange"
|
||||
> {
|
||||
interface Props extends Omit<
|
||||
React.ComponentProps<typeof RadixSwitch.Root>,
|
||||
"checked" | "onCheckedChange" | "onChange"
|
||||
> {
|
||||
/** Width of the switch. Defaults to 32. */
|
||||
width?: number;
|
||||
/** Height of the switch. Defaults to 18 */
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PlusIcon,
|
||||
InternetIcon,
|
||||
SmileyIcon,
|
||||
BuildingBlocksIcon,
|
||||
} from "outline-icons";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useEffect } from "react";
|
||||
@@ -110,7 +111,7 @@ const useSettingsConfig = () => {
|
||||
preload: APIAndApps.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: PadlockIcon,
|
||||
icon: BuildingBlocksIcon,
|
||||
},
|
||||
// Workspace
|
||||
{
|
||||
|
||||
@@ -52,6 +52,10 @@ class Team extends Model {
|
||||
@observable
|
||||
guestSignin: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
passkeysEnabled: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
subdomain: string | null | undefined;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
import { EmailIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { Client } from "@shared/types";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
@@ -9,6 +11,8 @@ import PluginIcon from "~/components/PluginIcon";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { getRedirectUrl } from "../urls";
|
||||
import { CSRF } from "@shared/constants";
|
||||
import { getCookie } from "tiny-cookie";
|
||||
|
||||
type Props = React.ComponentProps<typeof ButtonLarge> & {
|
||||
id: string;
|
||||
@@ -26,6 +30,7 @@ function AuthenticationProvider(props: Props) {
|
||||
const [authState, setAuthState] = React.useState<AuthState>("initial");
|
||||
const [isSubmitting, setSubmitting] = React.useState(false);
|
||||
const [email, setEmail] = React.useState("");
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
const { isCreate, id, name, authUrl, onEmailSuccess, ...rest } = props;
|
||||
const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web;
|
||||
|
||||
@@ -64,6 +69,78 @@ function AuthenticationProvider(props: Props) {
|
||||
|
||||
const href = getRedirectUrl(authUrl);
|
||||
|
||||
if (id === "passkeys") {
|
||||
const handleSubmitPasskey = async (
|
||||
event: React.SyntheticEvent<HTMLFormElement>
|
||||
) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
const resp = await client.post(
|
||||
"/passkeys.generateAuthenticationOptions",
|
||||
undefined,
|
||||
{
|
||||
baseUrl: "/auth",
|
||||
}
|
||||
);
|
||||
const { challengeId, ...optionsData } = resp.data;
|
||||
const authResp = await startAuthentication(optionsData);
|
||||
|
||||
// Populate hidden form fields with authentication data
|
||||
if (formRef.current) {
|
||||
const createInputs = (obj: any, prefix = "") => {
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
const fieldName = prefix ? `${prefix}[${key}]` : key;
|
||||
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
createInputs(value, fieldName);
|
||||
} else {
|
||||
// Create hidden input for primitive values
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = fieldName;
|
||||
input.value = String(value);
|
||||
formRef.current?.appendChild(input);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
createInputs({
|
||||
...authResp,
|
||||
challengeId,
|
||||
[CSRF.fieldName]: getCookie(CSRF.cookieName),
|
||||
client: clientType,
|
||||
});
|
||||
}
|
||||
|
||||
// Submit form natively to let browser handle redirect and cookies
|
||||
formRef.current?.submit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Form
|
||||
ref={formRef}
|
||||
method="POST"
|
||||
action="/auth/passkeys.verifyAuthentication"
|
||||
onSubmit={handleSubmitPasskey}
|
||||
>
|
||||
<ButtonLarge
|
||||
type="submit"
|
||||
icon={<PluginIcon id={id} color="currentColor" />}
|
||||
fullwidth
|
||||
{...rest}
|
||||
>
|
||||
{t("Continue with Passkey")}
|
||||
</ButtonLarge>
|
||||
</Form>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (id === "email") {
|
||||
if (isCreate) {
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PadlockIcon } from "outline-icons";
|
||||
import { BuildingBlocksIcon } from "outline-icons";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import type ApiKey from "~/models/ApiKey";
|
||||
import type OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
|
||||
@@ -29,7 +29,7 @@ function APIAndApps() {
|
||||
return (
|
||||
<Scene
|
||||
title={t("API & Apps")}
|
||||
icon={<PadlockIcon />}
|
||||
icon={<BuildingBlocksIcon />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
|
||||
@@ -190,6 +190,29 @@ function Authentication() {
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={
|
||||
<Flex gap={8} align="center">
|
||||
<PadlockIcon /> {t("Passkeys")}
|
||||
</Flex>
|
||||
}
|
||||
name="passkeysEnabled"
|
||||
description={t("Allow members to sign-in with a WebAuthn passkey")}
|
||||
>
|
||||
<Switch
|
||||
id="passkeysEnabled"
|
||||
checked={team.passkeysEnabled}
|
||||
onChange={async (checked) => {
|
||||
try {
|
||||
await team.save({ passkeysEnabled: checked });
|
||||
toast.success(t("Settings saved"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<Heading as="h2">{t("Restrictions")}</Heading>
|
||||
<DomainManagement onSuccess={showSuccessMessage} />
|
||||
</Scene>
|
||||
|
||||
@@ -30,6 +30,7 @@ function Security() {
|
||||
memberCollectionCreate: team.memberCollectionCreate,
|
||||
memberTeamCreate: team.memberTeamCreate,
|
||||
inviteRequired: team.inviteRequired,
|
||||
passkeysEnabled: team.passkeysEnabled,
|
||||
});
|
||||
|
||||
const userRoleOptions: Option[] = React.useMemo(
|
||||
@@ -91,6 +92,13 @@ function Security() {
|
||||
[saveData]
|
||||
);
|
||||
|
||||
const handlePasskeysEnabledChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
await saveData({ passkeysEnabled: checked });
|
||||
},
|
||||
[saveData]
|
||||
);
|
||||
|
||||
const handleMemberCollectionCreateChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
await saveData({ memberCollectionCreate: checked });
|
||||
@@ -231,6 +239,21 @@ function Security() {
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<Heading as="h2">{t("Authentication")}</Heading>
|
||||
<SettingRow
|
||||
label={t("Passkeys")}
|
||||
name="passkeysEnabled"
|
||||
description={t(
|
||||
"Allow users to sign in with passkeys for passwordless authentication"
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
id="passkeysEnabled"
|
||||
checked={data.passkeysEnabled}
|
||||
onChange={handlePasskeysEnabledChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<Heading as="h2">{t("Behavior")}</Heading>
|
||||
<SettingRow
|
||||
label={t("Public document sharing")}
|
||||
|
||||
Vendored
+1
-4
@@ -123,10 +123,7 @@ declare module "styled-components" {
|
||||
}
|
||||
|
||||
export interface DefaultTheme
|
||||
extends Colors,
|
||||
Spacing,
|
||||
Breakpoints,
|
||||
EditorTheme {
|
||||
extends Colors, Spacing, Breakpoints, EditorTheme {
|
||||
background: string;
|
||||
backgroundSecondary: string;
|
||||
backgroundTertiary: string;
|
||||
|
||||
@@ -34,6 +34,7 @@ interface FetchOptions {
|
||||
retry?: boolean;
|
||||
credentials?: "omit" | "same-origin" | "include";
|
||||
headers?: Record<string, string>;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
const fetchWithRetry = retry(fetch);
|
||||
@@ -97,7 +98,7 @@ class ApiClient {
|
||||
if (path.match(/^http/)) {
|
||||
urlToFetch = modifiedPath || path;
|
||||
} else {
|
||||
urlToFetch = this.baseUrl + (modifiedPath || path);
|
||||
urlToFetch = (options.baseUrl ?? this.baseUrl) + (modifiedPath || path);
|
||||
}
|
||||
|
||||
const headerOptions: Record<string, string> = {
|
||||
|
||||
+4
-2
@@ -11,7 +11,7 @@
|
||||
"build": "yarn clean && yarn vite:build && yarn build:i18n && yarn build:server",
|
||||
"start": "node ./build/server/index.js",
|
||||
"dev": "NODE_ENV=development yarn concurrently -n api,collaboration -c \"blue,magenta\" \"node --inspect=0.0.0.0 build/server/index.js --services=cron,collaboration,websockets,admin,web,worker\"",
|
||||
"dev:backend": "NODE_ENV=development nodemon --quiet --exec \"yarn build:server && yarn dev\" -e js,ts,tsx --watch server --watch shared --watch plugins --watch .env --watch .env.local --watch .env.development --ignore \"**/*.test.ts\" --ignore data/ --ignore build/ --ignore app/ --ignore shared/editor --ignore server/migrations",
|
||||
"dev:backend": "NODE_ENV=development nodemon --quiet --exec \"yarn build:server && yarn dev\" -e js,ts,tsx --watch server --watch shared --watch plugins --watch .env --watch .env.local --watch .env.development --ignore \"plugins/client/**/*.ts\" --ignore \"**/*.test.ts\" --ignore data/ --ignore build/ --ignore app/ --ignore shared/editor --ignore server/migrations",
|
||||
"dev:watch": "NODE_ENV=development yarn concurrently -n backend,frontend \"yarn dev:backend\" \"yarn vite:dev\"",
|
||||
"lint": "oxlint --type-aware app server shared plugins",
|
||||
"lint:changed": "git diff --name-only --diff-filter=ACMRTUXB | grep -E '\\.(js|jsx|ts|tsx)$' | xargs -r oxlint",
|
||||
@@ -105,6 +105,8 @@
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@sentry/node": "^7.120.4",
|
||||
"@sentry/react": "^7.120.4",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@types/form-data": "^2.5.2",
|
||||
@@ -182,7 +184,7 @@
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"octokit": "^3.2.2",
|
||||
"outline-icons": "^3.17.0",
|
||||
"outline-icons": "^3.18.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
"pako": "^2.1.0",
|
||||
"passport": "^0.7.0",
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { startRegistration } from "@simplewebauthn/browser";
|
||||
import { observer } from "mobx-react";
|
||||
import { KeyIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Button from "~/components/Button";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Heading from "~/components/Heading";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import PasskeyListItem from "./components/PasskeyListItem";
|
||||
import RenamePasskeyDialog from "./components/RenamePasskeyDialog";
|
||||
import { Action } from "~/components/Actions";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import Notice from "~/components/Notice";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
|
||||
type Passkey = {
|
||||
id: string;
|
||||
name: string;
|
||||
aaguid: string | null;
|
||||
userAgent: string | null;
|
||||
lastActiveAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function PasskeysSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const [passkeys, setPasskeys] = React.useState<Passkey[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [isRegistering, setIsRegistering] = React.useState(false);
|
||||
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
|
||||
const loadPasskeys = React.useCallback(async () => {
|
||||
try {
|
||||
const res = await client.post("/passkeys.list");
|
||||
setPasskeys(res.data || []);
|
||||
} catch (_err) {
|
||||
toast.error(t("Failed to load passkeys"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadPasskeys();
|
||||
}, [loadPasskeys]);
|
||||
|
||||
const handleRegister = async () => {
|
||||
setIsRegistering(true);
|
||||
try {
|
||||
const resp = await client.post(
|
||||
"/passkeys.generateRegistrationOptions",
|
||||
undefined,
|
||||
{
|
||||
baseUrl: "/auth",
|
||||
}
|
||||
);
|
||||
const attResp = await startRegistration(resp.data);
|
||||
await client.post("/passkeys.verifyRegistration", attResp as any, {
|
||||
baseUrl: "/auth",
|
||||
});
|
||||
toast.success(t("Passkey added successfully"));
|
||||
await loadPasskeys();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err.message || t("Failed to register passkey. Please try again.")
|
||||
);
|
||||
} finally {
|
||||
setIsRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = (passkeyId: string, currentName: string | null) => {
|
||||
dialogs.openModal({
|
||||
title: t("Rename passkey"),
|
||||
content: (
|
||||
<RenamePasskeyDialog
|
||||
passkeyId={passkeyId}
|
||||
currentName={currentName}
|
||||
onSuccess={async () => {
|
||||
await loadPasskeys();
|
||||
dialogs.closeAllModals();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (passkeyId: string) => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete passkey"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
try {
|
||||
await client.post("/passkeys.delete", { id: passkeyId });
|
||||
toast.success(t("Passkey deleted successfully"));
|
||||
await loadPasskeys();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err.message || t("Failed to delete passkey. Please try again.")
|
||||
);
|
||||
}
|
||||
}}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans>
|
||||
Are you sure you want to delete this passkey? You will no longer be
|
||||
able to use it to sign in.
|
||||
</Trans>
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("Passkeys")}
|
||||
icon={<KeyIcon />}
|
||||
actions={
|
||||
<Action>
|
||||
<Button
|
||||
onClick={handleRegister}
|
||||
disabled={isRegistering || !can.createUserPasskey}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{isRegistering ? `${t("Registering")}…` : `${t("Add Passkey")}…`}
|
||||
</Button>
|
||||
</Action>
|
||||
}
|
||||
>
|
||||
<Heading>{t("Passkeys")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
Passkeys allow you to sign in safely without a password using your
|
||||
device's biometric authentication (Face ID, Touch ID, Windows Hello)
|
||||
or security key.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{team.passkeysEnabled === false && (
|
||||
<Notice>
|
||||
{t("Sign-in with Passkey is currently disabled for this team.")}{" "}
|
||||
{can.update
|
||||
? t("Enable for all users in Settings -> Authentication.")
|
||||
: t("Contact a workspace admin to enable it.")}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<DelayedMount>
|
||||
<PlaceholderList count={5} />
|
||||
</DelayedMount>
|
||||
) : passkeys.length > 0 ? (
|
||||
<>
|
||||
{passkeys.map((pk) => (
|
||||
<PasskeyListItem
|
||||
key={pk.id}
|
||||
passkey={pk}
|
||||
onRename={() => handleRename(pk.id, pk.name)}
|
||||
onDelete={() => handleDelete(pk.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Text as="p" type="secondary">
|
||||
{t("You don't have any passkeys yet.")}
|
||||
</Text>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(PasskeysSettings);
|
||||
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
faChrome,
|
||||
faSafari,
|
||||
faFirefoxBrowser,
|
||||
faEdge,
|
||||
faOpera,
|
||||
faApple,
|
||||
faAndroid,
|
||||
faWindows,
|
||||
faGoogle,
|
||||
faLinux,
|
||||
} from "@fortawesome/free-brands-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { KeyIcon } from "outline-icons";
|
||||
import styled from "styled-components";
|
||||
import { PasskeyBrand } from "@shared/utils/passkeys";
|
||||
|
||||
interface PasskeyIconProps {
|
||||
passkey: {
|
||||
name: string;
|
||||
userAgent: string | null;
|
||||
aaguid: string | null;
|
||||
};
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an appropriate icon based on the passkey's AAGUID, browser, device, and authenticator type.
|
||||
*
|
||||
* @param passkey - passkey object with aaguid, name, and userAgent.
|
||||
* @param size - optional icon size in pixels.
|
||||
* @returns icon component representing the passkey type.
|
||||
*/
|
||||
function PasskeyIcon({ passkey, size = 24 }: PasskeyIconProps) {
|
||||
const { aaguid, userAgent } = passkey;
|
||||
|
||||
// Detect icon based on AAGUID
|
||||
const getAaguidIcon = () => {
|
||||
if (!aaguid) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (aaguid as PasskeyBrand) {
|
||||
case PasskeyBrand.ApplePasswords:
|
||||
case PasskeyBrand.ICloudKeychainManaged:
|
||||
case PasskeyBrand.IPasswords:
|
||||
return faApple;
|
||||
|
||||
case PasskeyBrand.WindowsHello1:
|
||||
case PasskeyBrand.WindowsHello2:
|
||||
case PasskeyBrand.WindowsHello3:
|
||||
case PasskeyBrand.MicrosoftPasswordManager:
|
||||
return faWindows;
|
||||
|
||||
case PasskeyBrand.GooglePasswordManager:
|
||||
return faGoogle;
|
||||
|
||||
case PasskeyBrand.ChromeOnMac:
|
||||
case PasskeyBrand.ChromiumBrowser:
|
||||
return faChrome;
|
||||
|
||||
case PasskeyBrand.EdgeOnMac:
|
||||
return faEdge;
|
||||
|
||||
case PasskeyBrand.SamsungPass:
|
||||
return faAndroid;
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Detect browser from user agent
|
||||
const getBrowserIcon = () => {
|
||||
if (!userAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ua = userAgent.toLowerCase();
|
||||
|
||||
// Edge (must check before Chrome)
|
||||
if (ua.includes("edg/") || ua.includes("edga/") || ua.includes("edgios/")) {
|
||||
return faEdge;
|
||||
}
|
||||
|
||||
// Opera (must check before Chrome)
|
||||
if (ua.includes("opr/") || ua.includes("opera")) {
|
||||
return faOpera;
|
||||
}
|
||||
|
||||
// Chrome
|
||||
if (ua.includes("chrome") && !ua.includes("edg")) {
|
||||
return faChrome;
|
||||
}
|
||||
|
||||
// Safari
|
||||
if (ua.includes("safari") && !ua.includes("chrome")) {
|
||||
return faSafari;
|
||||
}
|
||||
|
||||
// Firefox
|
||||
if (ua.includes("firefox")) {
|
||||
return faFirefoxBrowser;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Detect device from user agent
|
||||
const getDeviceIcon = () => {
|
||||
if (!userAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ua = userAgent.toLowerCase();
|
||||
|
||||
// iPhone or iPad
|
||||
if (ua.includes("iphone") || ua.includes("ipad")) {
|
||||
return faApple;
|
||||
}
|
||||
|
||||
// Android
|
||||
if (ua.includes("android")) {
|
||||
return faAndroid;
|
||||
}
|
||||
|
||||
// Windows
|
||||
if (ua.includes("windows")) {
|
||||
return faWindows;
|
||||
}
|
||||
|
||||
// Linux
|
||||
if (ua.includes("linux") && !ua.includes("android")) {
|
||||
return faLinux;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Determine which icon to show
|
||||
const aaguidIcon = getAaguidIcon();
|
||||
const browserIcon = getBrowserIcon();
|
||||
const deviceIcon = getDeviceIcon();
|
||||
|
||||
// Prioritize AAGUID icon, then browser icon, then device icon
|
||||
const faIcon = aaguidIcon || browserIcon || deviceIcon;
|
||||
|
||||
if (faIcon) {
|
||||
return (
|
||||
<FontAwesomeWrapper size={size}>
|
||||
<FontAwesomeIcon
|
||||
icon={faIcon}
|
||||
style={{
|
||||
width: size * 0.8,
|
||||
height: size * 0.8,
|
||||
}}
|
||||
/>
|
||||
</FontAwesomeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return <KeyIcon size={size} />;
|
||||
}
|
||||
|
||||
const FontAwesomeWrapper = styled.span<{ size: number }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
`;
|
||||
|
||||
export default PasskeyIcon;
|
||||
@@ -0,0 +1,105 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EditIcon, TrashIcon } from "outline-icons";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Text from "~/components/Text";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import { ActionSeparator, createAction } from "~/actions";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import PasskeyIcon from "./PasskeyIcon";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import Time from "~/components/Time";
|
||||
|
||||
type Passkey = {
|
||||
id: string;
|
||||
name: string;
|
||||
aaguid: string | null;
|
||||
userAgent: string | null;
|
||||
lastActiveAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
/** The passkey to render. */
|
||||
passkey: Passkey;
|
||||
/** Callback fired when the rename action is triggered. */
|
||||
onRename: () => void;
|
||||
/** Callback fired when the delete action is triggered. */
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function PasskeyMenu({ onRename, onDelete }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createAction({
|
||||
name: `${t("Rename")}…`,
|
||||
icon: <EditIcon />,
|
||||
section: "Passkey",
|
||||
perform: onRename,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: "Passkey",
|
||||
dangerous: true,
|
||||
perform: onDelete,
|
||||
}),
|
||||
],
|
||||
[t, onRename, onDelete]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<DropdownMenu action={rootAction} ariaLabel={t("Passkey options")}>
|
||||
<OverflowMenuButton neutral />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single passkey list item with icon, metadata, and action menu.
|
||||
*/
|
||||
function PasskeyListItem({ passkey, onRename, onDelete }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
image={<PasskeyIcon passkey={passkey} size={24} />}
|
||||
title={passkey.name}
|
||||
subtitle={
|
||||
passkey.lastActiveAt ? (
|
||||
<Text type="tertiary">
|
||||
{t("Last used")} <Time dateTime={passkey.lastActiveAt} addSuffix />
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="tertiary">
|
||||
{t("Registered {{ timeAgo }}", {
|
||||
timeAgo: dateToRelative(Date.parse(passkey.createdAt), {
|
||||
addSuffix: true,
|
||||
locale,
|
||||
}),
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<PasskeyMenu
|
||||
passkey={passkey}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasskeyListItem;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
interface RenamePasskeyDialogProps {
|
||||
passkeyId: string;
|
||||
currentName: string | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog component for renaming a passkey.
|
||||
*
|
||||
* @param passkeyId - the ID of the passkey to rename.
|
||||
* @param currentName - the current name of the passkey.
|
||||
* @param onSuccess - callback to be called after successful rename.
|
||||
* @returns dialog component for renaming passkeys.
|
||||
*/
|
||||
function RenamePasskeyDialog({
|
||||
passkeyId,
|
||||
currentName,
|
||||
onSuccess,
|
||||
}: RenamePasskeyDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState(currentName || "");
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await client.post("/passkeys.update", {
|
||||
id: passkeyId,
|
||||
name: name.trim(),
|
||||
});
|
||||
toast.success(t("Passkey updated"));
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err.message || t("Failed to update passkey. Please try again.")
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
{t("Give your passkey a memorable name to easily identify it.")}
|
||||
</Text>
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
placeholder={t("Enter passkey name")}
|
||||
autoFocus
|
||||
required
|
||||
maxLength={255}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<Flex justify="flex-end" gap={8}>
|
||||
<Button type="submit" disabled={isSaving || !name.trim()}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(RenamePasskeyDialog);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import { PluginManager, Hook } from "~/utils/PluginManager";
|
||||
import { KeyIcon } from "outline-icons";
|
||||
import config from "../plugin.json";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Icon,
|
||||
value: KeyIcon,
|
||||
},
|
||||
{
|
||||
...config,
|
||||
type: Hook.Settings,
|
||||
value: {
|
||||
group: "Account",
|
||||
after: "Notifications",
|
||||
icon: KeyIcon,
|
||||
description:
|
||||
"Manage your passkeys for passwordless authentication using biometrics or security keys.",
|
||||
component: createLazyComponent(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "passkeys",
|
||||
"name": "Passkeys",
|
||||
"priority": 100,
|
||||
"description": "Adds Passkeys authentication."
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { UserPasskey } from "@server/models";
|
||||
import type { APIContext } from "@server/types";
|
||||
import Router from "koa-router";
|
||||
import * as T from "./schema";
|
||||
import { authorize } from "@server/policies";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import pagination from "@server/routes/api/middlewares/pagination";
|
||||
import presentUserPasskey from "../presenters/userPasskey";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post(
|
||||
"passkeys.list",
|
||||
auth(),
|
||||
pagination(),
|
||||
validate(T.PasskeysListSchema),
|
||||
async (ctx: APIContext<T.PasskeysListReq>) => {
|
||||
const { user } = ctx.state.auth;
|
||||
const { pagination } = ctx.state;
|
||||
|
||||
const passkeys = await UserPasskey.findAll({
|
||||
where: { userId: user.id },
|
||||
order: [["createdAt", "DESC"]],
|
||||
offset: pagination.offset,
|
||||
limit: pagination.limit,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination,
|
||||
data: passkeys.map(presentUserPasskey),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"passkeys.update",
|
||||
auth(),
|
||||
validate(T.PasskeysUpdateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.PasskeysUpdateReq>) => {
|
||||
const { id, name } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const passkey = await UserPasskey.findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
authorize(user, "update", passkey);
|
||||
|
||||
await passkey.updateWithCtx(ctx, {
|
||||
name,
|
||||
});
|
||||
|
||||
ctx.body = { data: presentUserPasskey(passkey) };
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"passkeys.delete",
|
||||
auth(),
|
||||
validate(T.PasskeysDeleteSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.PasskeysDeleteReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const passkey = await UserPasskey.findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
authorize(user, "delete", passkey);
|
||||
|
||||
await passkey.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from "zod";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
import { UserPasskeyValidation } from "@shared/validations";
|
||||
|
||||
export const PasskeysListSchema = BaseSchema.extend({
|
||||
body: z.object({}),
|
||||
});
|
||||
|
||||
export type PasskeysListReq = z.infer<typeof PasskeysListSchema>;
|
||||
|
||||
export const PasskeysDeleteSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type PasskeysDeleteReq = z.infer<typeof PasskeysDeleteSchema>;
|
||||
|
||||
export const PasskeysUpdateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(UserPasskeyValidation.minNameLength)
|
||||
.max(UserPasskeyValidation.maxNameLength),
|
||||
}),
|
||||
});
|
||||
|
||||
export type PasskeysUpdateReq = z.infer<typeof PasskeysUpdateSchema>;
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import { isoBase64URL } from "@simplewebauthn/server/helpers";
|
||||
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
|
||||
import Router from "koa-router";
|
||||
import { randomBytes } from "crypto";
|
||||
import { User, UserPasskey, Team } from "@server/models";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import env from "@server/env";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import type { APIContext } from "@server/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import Redis from "@server/storage/redis";
|
||||
import { signIn } from "@server/utils/authentication";
|
||||
import { generatePasskeyName } from "@shared/utils/passkeys";
|
||||
import * as T from "./schema";
|
||||
import { Client } from "@shared/types";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { authorize } from "@server/policies";
|
||||
|
||||
const router = new Router();
|
||||
const rpName = env.APP_NAME;
|
||||
const CHALLENGE_EXPIRY_MS = Minute.ms * 5;
|
||||
|
||||
// Helper to get RP ID (domain) - for simplicity, we can use the hostname but strip port.
|
||||
const getRpID = (ctx: APIContext) => ctx.request.hostname;
|
||||
|
||||
/**
|
||||
* Generate Redis key for registration challenge.
|
||||
*
|
||||
* @param userId - the user ID.
|
||||
* @returns the Redis key.
|
||||
*/
|
||||
const getRegistrationChallengeKey = (userId: string): string =>
|
||||
`passkey:reg-challenge:${userId}`;
|
||||
|
||||
/**
|
||||
* Generate Redis key for authentication challenge.
|
||||
*
|
||||
* @param challengeId - the challenge ID.
|
||||
* @returns the Redis key.
|
||||
*/
|
||||
const getAuthenticationChallengeKey = (challengeId: string): string =>
|
||||
`passkey:auth-challenge:${challengeId}`;
|
||||
|
||||
router.post(
|
||||
"passkeys.generateRegistrationOptions",
|
||||
auth(),
|
||||
async (ctx: APIContext) => {
|
||||
const { user } = ctx.state.auth;
|
||||
authorize(user, "createUserPasskey", user.team);
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID: getRpID(ctx),
|
||||
userID: isoBase64URL.toBuffer(user.id),
|
||||
userName: user.email || user.name,
|
||||
// Don't exclude credentials, so we can detect if one is already registered (optional)
|
||||
authenticatorSelection: {
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
authenticatorAttachment: "platform",
|
||||
},
|
||||
});
|
||||
|
||||
// Save challenge to Redis with user ID as key
|
||||
await Redis.defaultClient.set(
|
||||
getRegistrationChallengeKey(user.id),
|
||||
options.challenge,
|
||||
"PX",
|
||||
CHALLENGE_EXPIRY_MS
|
||||
);
|
||||
|
||||
ctx.body = { data: options };
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"passkeys.verifyRegistration",
|
||||
auth(),
|
||||
validate(T.PasskeysVerifyRegistrationSchema),
|
||||
async (ctx: APIContext<T.PasskeysVerifyRegistrationReq>) => {
|
||||
const { user } = ctx.state.auth;
|
||||
const body = ctx.input.body;
|
||||
authorize(user, "createUserPasskey", user.team);
|
||||
|
||||
// Retrieve challenge from Redis
|
||||
const expectedChallenge = await Redis.defaultClient.get(
|
||||
getRegistrationChallengeKey(user.id)
|
||||
);
|
||||
|
||||
if (!expectedChallenge) {
|
||||
throw ValidationError(
|
||||
"No registration challenge found or challenge expired"
|
||||
);
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: body,
|
||||
expectedChallenge,
|
||||
expectedOrigin: `${ctx.protocol}://${ctx.request.host}`, // Origin includes port
|
||||
expectedRPID: getRpID(ctx),
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
Logger.error("passkeys: Registration verification failed", err);
|
||||
throw ValidationError(err.message);
|
||||
}
|
||||
|
||||
const { verified, registrationInfo } = verification;
|
||||
|
||||
if (verified && registrationInfo) {
|
||||
const { credential, aaguid } = registrationInfo;
|
||||
// credential.id is already a Base64URL string
|
||||
const credentialIdBase64 = credential.id;
|
||||
const credentialPublicKey = credential.publicKey;
|
||||
const counter = credential.counter;
|
||||
|
||||
// Capture user agent and generate friendly name
|
||||
const userAgent = ctx.request.get("user-agent");
|
||||
const transports = body.response.transports || [];
|
||||
|
||||
// Check if already exists
|
||||
const existing = await UserPasskey.findOne({
|
||||
where: { credentialId: credentialIdBase64 },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.userId !== user.id) {
|
||||
throw ValidationError("Passkey already registered to another user");
|
||||
}
|
||||
|
||||
await existing.updateWithCtx(ctx, {
|
||||
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||
counter,
|
||||
userAgent,
|
||||
aaguid,
|
||||
});
|
||||
} else {
|
||||
await UserPasskey.createWithCtx(ctx, {
|
||||
userId: user.id,
|
||||
credentialId: credentialIdBase64,
|
||||
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||
counter,
|
||||
transports,
|
||||
name: generatePasskeyName(aaguid, userAgent, transports),
|
||||
userAgent,
|
||||
aaguid,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete challenge from Redis
|
||||
await Redis.defaultClient.del(getRegistrationChallengeKey(user.id));
|
||||
ctx.body = { data: { verified: true } };
|
||||
} else {
|
||||
throw ValidationError("Verification failed");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"passkeys.generateAuthenticationOptions",
|
||||
validate(T.PasskeysGenerateAuthenticationOptionsSchema),
|
||||
async (ctx: APIContext<T.PasskeysGenerateAuthenticationOptionsReq>) => {
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: getRpID(ctx),
|
||||
userVerification: "preferred",
|
||||
});
|
||||
|
||||
// Generate a unique challenge ID for this authentication attempt
|
||||
const challengeId = randomBytes(32).toString("hex");
|
||||
await Redis.defaultClient.set(
|
||||
getAuthenticationChallengeKey(challengeId),
|
||||
options.challenge,
|
||||
"PX",
|
||||
CHALLENGE_EXPIRY_MS
|
||||
);
|
||||
|
||||
ctx.body = { data: { ...options, challengeId } };
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"passkeys.verifyAuthentication",
|
||||
validate(T.PasskeysVerifyAuthenticationSchema),
|
||||
async (ctx: APIContext<T.PasskeysVerifyAuthenticationReq>) => {
|
||||
const body = ctx.input.body;
|
||||
const { challengeId, client = Client.Web } = body;
|
||||
|
||||
// Retrieve challenge from Redis
|
||||
const expectedChallenge = await Redis.defaultClient.get(
|
||||
getAuthenticationChallengeKey(challengeId)
|
||||
);
|
||||
|
||||
if (!expectedChallenge) {
|
||||
throw ValidationError(
|
||||
"No authentication challenge found or challenge expired"
|
||||
);
|
||||
}
|
||||
|
||||
const credentialId = body.id;
|
||||
const passkey = await UserPasskey.findOne({
|
||||
where: { credentialId },
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "user",
|
||||
include: [{ model: Team, as: "team", required: true }],
|
||||
},
|
||||
],
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
const user = passkey.user;
|
||||
const team = user.team;
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: body,
|
||||
expectedChallenge,
|
||||
expectedOrigin: `${ctx.protocol}://${ctx.request.host}`,
|
||||
expectedRPID: getRpID(ctx),
|
||||
credential: {
|
||||
id: passkey.credentialId,
|
||||
publicKey: new Uint8Array(passkey.credentialPublicKey),
|
||||
counter: passkey.counter,
|
||||
transports: passkey.transports as AuthenticatorTransportFuture[],
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error("passkeys: Authentication verification failed", err);
|
||||
throw ValidationError("Passkey authentication failed. Please try again.");
|
||||
}
|
||||
|
||||
const { verified, authenticationInfo } = verification;
|
||||
|
||||
if (verified && authenticationInfo) {
|
||||
// Update counter
|
||||
passkey.counter = authenticationInfo.newCounter;
|
||||
passkey.lastActiveAt = new Date();
|
||||
await passkey.save({ silent: true });
|
||||
|
||||
// Delete challenge from Redis
|
||||
await Redis.defaultClient.del(getAuthenticationChallengeKey(challengeId));
|
||||
|
||||
// Use the signIn utility which handles all sign-in logic
|
||||
await signIn(ctx, "passkeys", {
|
||||
user,
|
||||
team,
|
||||
isNewUser: false,
|
||||
isNewTeam: false,
|
||||
client,
|
||||
});
|
||||
} else {
|
||||
throw ValidationError("Verification failed");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { z } from "zod";
|
||||
import { Client } from "@shared/types";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
|
||||
export const PasskeysGenerateAuthenticationOptionsSchema = BaseSchema.extend({
|
||||
body: z.object({}),
|
||||
query: z.object({
|
||||
client: z.nativeEnum(Client).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type PasskeysGenerateAuthenticationOptionsReq = z.infer<
|
||||
typeof PasskeysGenerateAuthenticationOptionsSchema
|
||||
>;
|
||||
|
||||
export const PasskeysVerifyAuthenticationSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
challengeId: z.string(),
|
||||
client: z.nativeEnum(Client).optional(),
|
||||
id: z.string(),
|
||||
rawId: z.string(),
|
||||
response: z.object({
|
||||
authenticatorData: z.string(),
|
||||
clientDataJSON: z.string(),
|
||||
signature: z.string(),
|
||||
userHandle: z.string().optional(),
|
||||
}),
|
||||
type: z.literal("public-key"),
|
||||
authenticatorAttachment: z.enum(["cross-platform", "platform"]).optional(),
|
||||
clientExtensionResults: z
|
||||
.object({
|
||||
appid: z.boolean().optional(),
|
||||
hmacCreateSecret: z.boolean().optional(),
|
||||
})
|
||||
.default({}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type PasskeysVerifyAuthenticationReq = z.infer<
|
||||
typeof PasskeysVerifyAuthenticationSchema
|
||||
>;
|
||||
|
||||
export const PasskeysVerifyRegistrationSchema = BaseSchema.extend({
|
||||
body: z.any(), // WebAuthn RegistrationResponseJSON from @simplewebauthn/browser
|
||||
});
|
||||
|
||||
export type PasskeysVerifyRegistrationReq = z.infer<
|
||||
typeof PasskeysVerifyRegistrationSchema
|
||||
>;
|
||||
@@ -0,0 +1,91 @@
|
||||
import env from "@server/env";
|
||||
import type { EmailProps } from "@server/emails/templates/BaseEmail";
|
||||
import BaseEmail, {
|
||||
EmailMessageCategory,
|
||||
} from "@server/emails/templates/BaseEmail";
|
||||
import Body from "@server/emails/templates/components/Body";
|
||||
import Button from "@server/emails/templates/components/Button";
|
||||
import EmailTemplate from "@server/emails/templates/components/EmailLayout";
|
||||
import EmptySpace from "@server/emails/templates/components/EmptySpace";
|
||||
import Footer from "@server/emails/templates/components/Footer";
|
||||
import Header from "@server/emails/templates/components/Header";
|
||||
import Heading from "@server/emails/templates/components/Heading";
|
||||
|
||||
type InputProps = EmailProps & {
|
||||
userId: string;
|
||||
passkeyId: string;
|
||||
passkeyName: string;
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
type Props = InputProps;
|
||||
|
||||
/**
|
||||
* Email sent to a user when a new passkey is created on their account.
|
||||
*/
|
||||
export class PasskeyCreatedEmail extends BaseEmail<InputProps> {
|
||||
protected get category() {
|
||||
return EmailMessageCategory.Notification;
|
||||
}
|
||||
|
||||
protected subject() {
|
||||
return `New passkey added to your ${env.APP_NAME} account`;
|
||||
}
|
||||
|
||||
protected preview() {
|
||||
return "A new passkey was created for your account.";
|
||||
}
|
||||
|
||||
protected renderAsText({ passkeyName, teamUrl }: Props) {
|
||||
return `
|
||||
New Passkey Created
|
||||
|
||||
A new passkey has been added to your ${env.APP_NAME} account:
|
||||
|
||||
${passkeyName}
|
||||
|
||||
Passkeys provide a secure, passwordless way to sign in to your account. If you did not create this passkey, please review your account security settings immediately.
|
||||
|
||||
You can manage your passkeys at any time:
|
||||
${teamUrl}/settings/passkeys
|
||||
|
||||
---
|
||||
|
||||
If you have any concerns about your account security, please contact a workspace admin.
|
||||
`;
|
||||
}
|
||||
|
||||
protected render({ passkeyName, teamUrl }: Props) {
|
||||
const securityUrl = `${teamUrl}/settings/passkeys`;
|
||||
|
||||
return (
|
||||
<EmailTemplate previewText={this.preview()}>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>New Passkey Created</Heading>
|
||||
<p>A new passkey has been added to your {env.APP_NAME} account:</p>
|
||||
<p>
|
||||
<strong>{passkeyName}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Passkeys provide a secure, passwordless way to sign in to your
|
||||
account. If you did not create this passkey, please review your
|
||||
account security settings immediately.
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={securityUrl}>Manage Passkeys</Button>
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p style={{ fontSize: "14px", color: "#666" }}>
|
||||
If you have any concerns about your account security, please contact
|
||||
a workspace admin.
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
<Footer />
|
||||
</EmailTemplate>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { PluginManager, Hook } from "@server/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import router from "./auth/passkeys";
|
||||
import api from "./api/passkeys";
|
||||
import { PasskeyCreatedProcessor } from "./processors/PasskeyCreatedProcessor";
|
||||
import { PasskeyCreatedEmail } from "./email/templates/PasskeyCreatedEmail";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.AuthProvider,
|
||||
value: { router, id: config.id },
|
||||
},
|
||||
{
|
||||
...config,
|
||||
type: Hook.API,
|
||||
value: api,
|
||||
},
|
||||
{
|
||||
type: Hook.Processor,
|
||||
value: PasskeyCreatedProcessor,
|
||||
},
|
||||
{
|
||||
type: Hook.EmailTemplate,
|
||||
value: PasskeyCreatedEmail,
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,13 @@
|
||||
import type UserPasskey from "@server/models/UserPasskey";
|
||||
|
||||
export default function presentUserPasskey(userPasskey: UserPasskey) {
|
||||
return {
|
||||
id: userPasskey.id,
|
||||
createdAt: userPasskey.createdAt,
|
||||
updatedAt: userPasskey.updatedAt,
|
||||
lastActiveAt: userPasskey.lastActiveAt,
|
||||
name: userPasskey.name,
|
||||
userAgent: userPasskey.userAgent,
|
||||
aaguid: userPasskey.aaguid,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { UserPasskey, User } from "@server/models";
|
||||
import type { Event, UserPasskeyEvent } from "@server/types";
|
||||
import BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import { PasskeyCreatedEmail } from "../email/templates/PasskeyCreatedEmail";
|
||||
|
||||
export class PasskeyCreatedProcessor extends BaseProcessor {
|
||||
static applicableEvents: Event["name"][] = ["passkeys.create"];
|
||||
|
||||
async perform(event: UserPasskeyEvent) {
|
||||
const userPasskey = await UserPasskey.findByPk(event.modelId);
|
||||
if (!userPasskey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await User.scope("withTeam").findByPk(event.userId);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new PasskeyCreatedEmail({
|
||||
to: user.email,
|
||||
userId: user.id,
|
||||
passkeyId: userPasskey.id,
|
||||
passkeyName: userPasskey.name,
|
||||
teamUrl: user.team.url,
|
||||
}).schedule();
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,11 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
case "oauthClients.delete":
|
||||
// Ignored
|
||||
return;
|
||||
case "passkeys.create":
|
||||
case "passkeys.update":
|
||||
case "passkeys.delete":
|
||||
// Ignored
|
||||
return;
|
||||
default:
|
||||
assertUnreachable(event);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ View Document: ${teamUrl}${collection.path}
|
||||
<Heading>{collection.name}</Heading>
|
||||
<p>
|
||||
{actorName} invited you to {permission} documents in the{" "}
|
||||
<a href={collectionUrl}>{collection.name}</a>{" "}collection.
|
||||
<a href={collectionUrl}>{collection.name}</a> collection.
|
||||
</p>
|
||||
<p>
|
||||
<Button href={collectionUrl}>View Collection</Button>
|
||||
|
||||
@@ -94,7 +94,7 @@ View Document: ${teamUrl}${document.path}
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<p>
|
||||
{actorName} invited you to {permission} the{" "}
|
||||
<a href={documentUrl}>{document.titleWithDefault}</a>{" "}document.
|
||||
<a href={documentUrl}>{document.titleWithDefault}</a> document.
|
||||
</p>
|
||||
<p>
|
||||
<Button href={documentUrl}>View Document</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import { requireDirectory } from "@server/utils/fs";
|
||||
import type BaseEmail from "./BaseEmail";
|
||||
|
||||
const emails: Record<string, typeof BaseEmail> = {};
|
||||
const emails: Record<string, typeof BaseEmail<any>> = {};
|
||||
|
||||
requireDirectory<{ default: typeof BaseEmail }>(__dirname).forEach(
|
||||
([module, id]) => {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("teams", "passkeysEnabled", {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn("teams", "passkeysEnabled");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
return queryInterface.sequelize.transaction(async (transaction) => {
|
||||
await queryInterface.createTable(
|
||||
"user_passkeys",
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
userAgent: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
credentialId: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
credentialPublicKey: {
|
||||
type: Sequelize.BLOB,
|
||||
allowNull: false,
|
||||
},
|
||||
aaguid: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
counter: {
|
||||
type: Sequelize.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
transports: {
|
||||
type: Sequelize.ARRAY(Sequelize.STRING),
|
||||
allowNull: true,
|
||||
},
|
||||
lastActiveAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
userId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "users",
|
||||
key: "id",
|
||||
},
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await queryInterface.addIndex("user_passkeys", ["userId"], {
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
return queryInterface.sequelize.transaction(async (transaction) => {
|
||||
await queryInterface.dropTable("user_passkeys", { transaction });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -153,6 +153,10 @@ class Team extends ParanoidModel<
|
||||
@Column
|
||||
guestSignin: boolean;
|
||||
|
||||
@Default(true)
|
||||
@Column
|
||||
passkeysEnabled: boolean;
|
||||
|
||||
@Default(true)
|
||||
@Column
|
||||
documentEmbeds: boolean;
|
||||
|
||||
@@ -61,6 +61,7 @@ import Group from "./Group";
|
||||
import Team from "./Team";
|
||||
import UserAuthentication from "./UserAuthentication";
|
||||
import UserMembership from "./UserMembership";
|
||||
import UserPasskey from "./UserPasskey";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Encrypted from "./decorators/Encrypted";
|
||||
import Fix from "./decorators/Fix";
|
||||
@@ -249,6 +250,9 @@ class User extends ParanoidModel<
|
||||
@HasMany(() => UserAuthentication)
|
||||
authentications: UserAuthentication[];
|
||||
|
||||
@HasMany(() => UserPasskey)
|
||||
passkeys: UserPasskey[];
|
||||
|
||||
// getters
|
||||
|
||||
get isSuspended(): boolean {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { InferAttributes, InferCreationAttributes } from "sequelize";
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
IsDate,
|
||||
Length,
|
||||
Table,
|
||||
} from "sequelize-typescript";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
import { UserPasskeyValidation } from "@shared/validations";
|
||||
import NotContainsUrl from "./validators/NotContainsUrl";
|
||||
import { SkipChangeset } from "./decorators/Changeset";
|
||||
|
||||
@Table({ tableName: "user_passkeys", modelName: "user_passkey" })
|
||||
@Fix
|
||||
class UserPasskey extends IdModel<
|
||||
InferAttributes<UserPasskey>,
|
||||
Partial<InferCreationAttributes<UserPasskey>>
|
||||
> {
|
||||
static eventNamespace = "passkeys";
|
||||
|
||||
@Column
|
||||
credentialId: string;
|
||||
|
||||
@Column(DataType.BLOB)
|
||||
@SkipChangeset
|
||||
credentialPublicKey: Buffer;
|
||||
|
||||
@Column
|
||||
aaguid: string | null;
|
||||
|
||||
@Column(DataType.BIGINT)
|
||||
@SkipChangeset
|
||||
counter: number;
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
transports: string[];
|
||||
|
||||
@Length({
|
||||
min: UserPasskeyValidation.minNameLength,
|
||||
max: UserPasskeyValidation.maxNameLength,
|
||||
msg: `Name must be between ${UserPasskeyValidation.minNameLength} and ${UserPasskeyValidation.maxNameLength} characters`,
|
||||
})
|
||||
@NotContainsUrl
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
@Column
|
||||
userAgent: string | null;
|
||||
|
||||
@IsDate
|
||||
@Column
|
||||
@SkipChangeset
|
||||
lastActiveAt: Date | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export default UserPasskey;
|
||||
@@ -26,7 +26,9 @@ export default class AuthenticationHelper {
|
||||
const isCloudHosted = env.isCloudHosted;
|
||||
|
||||
return AuthenticationHelper.providers
|
||||
.sort((hook) => (hook.value.id === "email" ? 1 : -1))
|
||||
.sort((hook) =>
|
||||
hook.value.id === "email" || hook.value.id === "passkeys" ? 1 : -1
|
||||
)
|
||||
.filter((hook) => {
|
||||
// Email sign-in is an exception as it does not have an authentication
|
||||
// provider using passport, instead it exists as a boolean option.
|
||||
@@ -34,7 +36,13 @@ export default class AuthenticationHelper {
|
||||
return team?.emailSigninEnabled;
|
||||
}
|
||||
|
||||
// If no team return all possible authentication providers except email.
|
||||
// Passkeys is an exception as it does not have an authentication
|
||||
// provider using passport, instead it exists as a boolean option.
|
||||
if (hook.value.id === "passkeys") {
|
||||
return team?.passkeysEnabled;
|
||||
}
|
||||
|
||||
// If no team return all possible authentication providers except email and passkeys.
|
||||
if (!team) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -69,3 +69,4 @@ export { default as WebhookDelivery } from "./WebhookDelivery";
|
||||
export { default as Subscription } from "./Subscription";
|
||||
|
||||
export { default as Emoji } from "./Emoji";
|
||||
export { default as UserPasskey } from "./UserPasskey";
|
||||
|
||||
@@ -26,4 +26,5 @@ import "./team";
|
||||
import "./group";
|
||||
import "./webhookSubscription";
|
||||
import "./userMembership";
|
||||
import "./userPasskey";
|
||||
import "./emoji";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { UserPasskey, User, Team } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import { and, isTeamModel, isTeamMutable } from "./utils";
|
||||
|
||||
allow(User, "createUserPasskey", Team, (actor, team) =>
|
||||
and(isTeamModel(actor, team), isTeamMutable(actor), !!team?.passkeysEnabled)
|
||||
);
|
||||
|
||||
allow(
|
||||
User,
|
||||
["read", "update", "delete"],
|
||||
UserPasskey,
|
||||
(actor, passkey) => passkey?.userId === actor.id
|
||||
);
|
||||
@@ -12,6 +12,7 @@ export default function presentTeam(team: Team) {
|
||||
defaultCollectionId: team.defaultCollectionId,
|
||||
documentEmbeds: team.documentEmbeds,
|
||||
guestSignin: team.emailSigninEnabled,
|
||||
passkeysEnabled: team.passkeysEnabled,
|
||||
subdomain: team.subdomain,
|
||||
domain: team.domain,
|
||||
url: team.url,
|
||||
|
||||
@@ -5,13 +5,7 @@ import {
|
||||
} from "@shared/types";
|
||||
import subscriptionCreator from "@server/commands/subscriptionCreator";
|
||||
import { createContext } from "@server/context";
|
||||
import {
|
||||
Comment,
|
||||
Document,
|
||||
Group,
|
||||
Notification,
|
||||
User,
|
||||
} from "@server/models";
|
||||
import { Comment, Document, Group, Notification, User } from "@server/models";
|
||||
import NotificationHelper from "@server/models/helpers/NotificationHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import invariant from "invariant";
|
||||
import { Op } from "sequelize";
|
||||
import { MentionType, NotificationEventType } from "@shared/types";
|
||||
import {
|
||||
Comment,
|
||||
Document,
|
||||
Group,
|
||||
Notification,
|
||||
User,
|
||||
} from "@server/models";
|
||||
import { Comment, Document, Group, Notification, User } from "@server/models";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import type { CommentEvent, CommentUpdateEvent } from "@server/types";
|
||||
import { canUserAccessDocument } from "@server/utils/permissions";
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
import { ApiKeyValidation } from "@shared/validations";
|
||||
|
||||
export const APIKeysCreateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** API Key name */
|
||||
name: z.string(),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(ApiKeyValidation.minNameLength)
|
||||
.max(ApiKeyValidation.maxNameLength),
|
||||
/** API Key expiry date */
|
||||
expiresAt: z.coerce.date().optional(),
|
||||
/** A list of scopes that this API key has access to */
|
||||
|
||||
@@ -105,7 +105,7 @@ router.post(
|
||||
)) as AuthenticationProvider[];
|
||||
|
||||
const data = AuthenticationHelper.providers
|
||||
.filter((p) => p.value.id !== "email")
|
||||
.filter((p) => p.value.id !== "email" && p.value.id !== "passkeys")
|
||||
.map((p) => {
|
||||
const row = teamAuthenticationProviders.find(
|
||||
(t) => t.name === p.value.id
|
||||
|
||||
@@ -14,8 +14,10 @@ export const TeamsUpdateSchema = BaseSchema.extend({
|
||||
subdomain: z.string().nullish(),
|
||||
/** Whether public sharing is enabled */
|
||||
sharing: z.boolean().optional(),
|
||||
/** Whether siginin with email is enabled */
|
||||
/** Whether signin with email is enabled */
|
||||
guestSignin: z.boolean().optional(),
|
||||
/** Whether signin with passkeys is enabled */
|
||||
passkeysEnabled: z.boolean().optional(),
|
||||
/** Whether third-party document embeds are enabled */
|
||||
documentEmbeds: z.boolean().optional(),
|
||||
/** Whether team members are able to create new collections */
|
||||
|
||||
@@ -153,6 +153,7 @@ export function buildTeam(
|
||||
return Team.create(
|
||||
{
|
||||
name: faker.company.name(),
|
||||
passkeysEnabled: false,
|
||||
authenticationProviders: [
|
||||
{
|
||||
name: "slack",
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
Team,
|
||||
User,
|
||||
UserMembership,
|
||||
UserPasskey,
|
||||
WebhookSubscription,
|
||||
Pin,
|
||||
Star,
|
||||
@@ -440,6 +441,12 @@ export type OAuthClientEvent = BaseEvent<OAuthClient> & {
|
||||
modelId: string;
|
||||
};
|
||||
|
||||
export type UserPasskeyEvent = BaseEvent<UserPasskey> & {
|
||||
name: "passkeys.create" | "passkeys.update" | "passkeys.delete";
|
||||
modelId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type ImportEvent = BaseEvent<Import<any>> & {
|
||||
name:
|
||||
@@ -477,6 +484,7 @@ export type Event =
|
||||
| WebhookSubscriptionEvent
|
||||
| NotificationEvent
|
||||
| OAuthClientEvent
|
||||
| UserPasskeyEvent
|
||||
| EmptyTrashEvent
|
||||
| ImportEvent;
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export enum Hook {
|
||||
type PluginValueMap = {
|
||||
[Hook.API]: Router;
|
||||
[Hook.AuthProvider]: { router: Router | Promise<Router>; id: string };
|
||||
[Hook.EmailTemplate]: typeof BaseEmail;
|
||||
[Hook.EmailTemplate]: typeof BaseEmail<any>;
|
||||
[Hook.IssueProvider]: BaseIssueProvider;
|
||||
[Hook.Processor]: typeof BaseProcessor;
|
||||
[Hook.Task]: typeof BaseTask<any>;
|
||||
|
||||
@@ -897,6 +897,7 @@
|
||||
"Triggers": "Triggers",
|
||||
"Mention users and more": "Mention users and more",
|
||||
"Insert block": "Insert block",
|
||||
"Continue with Passkey": "Continue with Passkey",
|
||||
"Sign In": "Sign In",
|
||||
"Continue with Email": "Continue with Email",
|
||||
"Continue with {{ authProviderName }}": "Continue with {{ authProviderName }}",
|
||||
@@ -1028,6 +1029,8 @@
|
||||
"Connect": "Connect",
|
||||
"Allow members to sign-in using their email address": "Allow members to sign-in using their email address",
|
||||
"The server must have SMTP configured to enable this setting": "The server must have SMTP configured to enable this setting",
|
||||
"Passkeys": "Passkeys",
|
||||
"Allow members to sign-in with a WebAuthn passkey": "Allow members to sign-in with a WebAuthn passkey",
|
||||
"Restrictions": "Restrictions",
|
||||
"by {{ name }}": "by {{ name }}",
|
||||
"Expired": "Expired",
|
||||
@@ -1237,6 +1240,7 @@
|
||||
"Require members to be invited to the workspace before they can create an account using SSO.": "Require members to be invited to the workspace before they can create an account using SSO.",
|
||||
"Default role": "Default role",
|
||||
"The default user role for new accounts. Changing this setting does not affect existing user accounts.": "The default user role for new accounts. Changing this setting does not affect existing user accounts.",
|
||||
"Allow users to sign in with passkeys for passwordless authentication": "Allow users to sign in with passkeys for passwordless authentication",
|
||||
"When enabled, documents can be shared publicly on the internet by any member of the workspace": "When enabled, documents can be shared publicly on the internet by any member of the workspace",
|
||||
"Viewer document exports": "Viewer document exports",
|
||||
"When enabled, viewers can see download options for documents": "When enabled, viewers can see download options for documents",
|
||||
@@ -1375,6 +1379,27 @@
|
||||
"An ID that uniquely identifies the website in your Matomo instance.": "An ID that uniquely identifies the website in your Matomo instance.",
|
||||
"Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?": "Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?",
|
||||
"Import pages from Notion": "Import pages from Notion",
|
||||
"Passkey options": "Passkey options",
|
||||
"Registered {{ timeAgo }}": "Registered {{ timeAgo }}",
|
||||
"Passkey updated": "Passkey updated",
|
||||
"Failed to update passkey. Please try again.": "Failed to update passkey. Please try again.",
|
||||
"Give your passkey a memorable name to easily identify it.": "Give your passkey a memorable name to easily identify it.",
|
||||
"Enter passkey name": "Enter passkey name",
|
||||
"Failed to load passkeys": "Failed to load passkeys",
|
||||
"Passkey added successfully": "Passkey added successfully",
|
||||
"Failed to register passkey. Please try again.": "Failed to register passkey. Please try again.",
|
||||
"Rename passkey": "Rename passkey",
|
||||
"Delete passkey": "Delete passkey",
|
||||
"Passkey deleted successfully": "Passkey deleted successfully",
|
||||
"Failed to delete passkey. Please try again.": "Failed to delete passkey. Please try again.",
|
||||
"Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.": "Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.",
|
||||
"Registering": "Registering",
|
||||
"Add Passkey": "Add Passkey",
|
||||
"Passkeys allow you to sign in safely without a password using your device's biometric authentication (Face ID, Touch ID, Windows Hello) or security key.": "Passkeys allow you to sign in safely without a password using your device's biometric authentication (Face ID, Touch ID, Windows Hello) or security key.",
|
||||
"Sign-in with Passkey is currently disabled for this team.": "Sign-in with Passkey is currently disabled for this team.",
|
||||
"Enable for all users in Settings -> Authentication.": "Enable for all users in Settings -> Authentication.",
|
||||
"Contact a workspace admin to enable it.": "Contact a workspace admin to enable it.",
|
||||
"You don't have any passkeys yet.": "You don't have any passkeys yet.",
|
||||
"Add to Slack": "Add to Slack",
|
||||
"document published": "document published",
|
||||
"document updated": "document updated",
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { generatePasskeyName } from "./passkeys";
|
||||
|
||||
describe("generatePasskeyName", () => {
|
||||
describe("AAGUID-based brand detection", () => {
|
||||
it("detects 1Password", () => {
|
||||
const aaguid = "bada5566-a7aa-401f-bd96-45619a55120d";
|
||||
const name = generatePasskeyName(aaguid);
|
||||
expect(name).toBe("1Password");
|
||||
});
|
||||
|
||||
it("detects Google Password Manager", () => {
|
||||
const aaguid = "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4";
|
||||
const name = generatePasskeyName(aaguid);
|
||||
expect(name).toBe("Google Password Manager");
|
||||
});
|
||||
|
||||
it("detects Windows Hello", () => {
|
||||
const aaguid = "08987058-cadc-4b81-b6e1-30de50dcbe96";
|
||||
const name = generatePasskeyName(aaguid);
|
||||
expect(name).toBe("Windows Hello");
|
||||
});
|
||||
|
||||
it("detects iCloud Keychain (Managed)", () => {
|
||||
const aaguid = "dd4ec289-e01d-41c9-bb89-70fa845d4bf2";
|
||||
const name = generatePasskeyName(aaguid);
|
||||
expect(name).toBe("iCloud Keychain (Managed)");
|
||||
});
|
||||
|
||||
it("detects Bitwarden", () => {
|
||||
const aaguid = "d548826e-79b4-db40-a3d8-11116f7e8349";
|
||||
const name = generatePasskeyName(aaguid);
|
||||
expect(name).toBe("Bitwarden");
|
||||
});
|
||||
|
||||
it("prefers AAGUID over user agent", () => {
|
||||
const aaguid = "bada5566-a7aa-401f-bd96-45619a55120d";
|
||||
const ua =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
const name = generatePasskeyName(aaguid, ua, ["internal"]);
|
||||
expect(name).toBe("1Password");
|
||||
expect(name).not.toContain("Chrome");
|
||||
});
|
||||
|
||||
it("falls back to user agent when AAGUID is unknown", () => {
|
||||
const aaguid = "00000000-0000-0000-0000-000000000000";
|
||||
const ua =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
const name = generatePasskeyName(aaguid, ua, ["internal"]);
|
||||
expect(name).toBe("Chrome on macOS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser detection", () => {
|
||||
it("detects Chrome on macOS", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
const name = generatePasskeyName(undefined, ua, ["internal"]);
|
||||
expect(name).toBe("Chrome on macOS");
|
||||
});
|
||||
|
||||
it("detects Safari on macOS", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
|
||||
const name = generatePasskeyName(undefined, ua, ["internal"]);
|
||||
expect(name).toBe("Safari on macOS");
|
||||
});
|
||||
|
||||
it("detects Firefox on Windows", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0";
|
||||
const name = generatePasskeyName(undefined, ua, ["internal"]);
|
||||
expect(name).toBe("Firefox on Windows");
|
||||
});
|
||||
|
||||
it("detects Edge on Windows", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0";
|
||||
const name = generatePasskeyName(undefined, ua, ["internal"]);
|
||||
expect(name).toBe("Edge on Windows");
|
||||
});
|
||||
|
||||
it("detects Opera on Linux", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0";
|
||||
const name = generatePasskeyName(undefined, ua, ["internal"]);
|
||||
expect(name).toBe("Opera on Linux");
|
||||
});
|
||||
});
|
||||
|
||||
describe("device detection", () => {
|
||||
it("detects iPhone", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
||||
const name = generatePasskeyName(undefined, ua, ["internal"]);
|
||||
expect(name).toBe("iPhone");
|
||||
});
|
||||
|
||||
it("detects iPad", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
||||
const name = generatePasskeyName(undefined, ua, ["internal"]);
|
||||
expect(name).toBe("iPad");
|
||||
});
|
||||
|
||||
it("detects Android Phone", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
|
||||
const name = generatePasskeyName(undefined, ua, ["internal"]);
|
||||
expect(name).toBe("Android Phone");
|
||||
});
|
||||
|
||||
it("detects Android Tablet", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (Linux; Android 13; Pixel Tablet) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
const name = generatePasskeyName(undefined, ua, ["internal"]);
|
||||
expect(name).toBe("Android Tablet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OS detection", () => {
|
||||
it("detects Chrome OS", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
const name = generatePasskeyName(undefined, ua, ["internal"]);
|
||||
expect(name).toBe("Chrome on Chrome OS");
|
||||
});
|
||||
|
||||
it("detects Linux", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
|
||||
const name = generatePasskeyName(undefined, ua, ["internal"]);
|
||||
expect(name).toBe("Firefox on Linux");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticator type detection", () => {
|
||||
it("detects security key (USB)", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
const name = generatePasskeyName(undefined, ua, ["usb"]);
|
||||
expect(name).toContain("Security Key");
|
||||
});
|
||||
|
||||
it("detects security key (NFC)", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
const name = generatePasskeyName(undefined, ua, ["nfc"]);
|
||||
expect(name).toContain("Security Key");
|
||||
});
|
||||
|
||||
it("detects security key (BLE)", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
const name = generatePasskeyName(undefined, ua, ["ble"]);
|
||||
expect(name).toContain("Security Key");
|
||||
});
|
||||
|
||||
it("detects hybrid authenticator (phone)", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
const name = generatePasskeyName(undefined, ua, ["hybrid"]);
|
||||
expect(name).toContain("Phone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles missing user agent", () => {
|
||||
const name = generatePasskeyName(undefined, undefined, ["internal"]);
|
||||
expect(name).toBe("Passkey");
|
||||
});
|
||||
|
||||
it("handles empty user agent", () => {
|
||||
const name = generatePasskeyName(undefined, "", ["usb"]);
|
||||
expect(name).toBe("Security Key");
|
||||
});
|
||||
|
||||
it("handles unknown user agent", () => {
|
||||
const name = generatePasskeyName(undefined, "Unknown/1.0");
|
||||
expect(name).toBe("Passkey");
|
||||
});
|
||||
|
||||
it("handles missing transports", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
const name = generatePasskeyName(undefined, ua, []);
|
||||
expect(name).toBe("Chrome on macOS");
|
||||
});
|
||||
|
||||
it("handles no parameters", () => {
|
||||
const name = generatePasskeyName();
|
||||
expect(name).toBe("Passkey");
|
||||
});
|
||||
|
||||
it("prioritizes device name over browser/OS", () => {
|
||||
const ua =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
||||
const name = generatePasskeyName(undefined, ua, ["internal"]);
|
||||
expect(name).toBe("iPhone");
|
||||
expect(name).not.toContain("Safari");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Utility functions for working with passkeys and WebAuthn.
|
||||
*/
|
||||
|
||||
interface ParsedUserAgent {
|
||||
browser?: string;
|
||||
os?: string;
|
||||
device?: string;
|
||||
}
|
||||
|
||||
export enum PasskeyBrand {
|
||||
AliasVault = "a11a5faa-9f32-4b8c-8c5d-2f7d13e8c942",
|
||||
GooglePasswordManager = "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4",
|
||||
ChromeOnMac = "adce0002-35bc-c60a-648b-0b25f1f05503",
|
||||
WindowsHello1 = "08987058-cadc-4b81-b6e1-30de50dcbe96",
|
||||
WindowsHello2 = "9ddd1817-af5a-4672-a2b9-3e3dd95000a9",
|
||||
WindowsHello3 = "6028b017-b1d4-4c02-b4b3-afcdafc96bb2",
|
||||
ICloudKeychainManaged = "dd4ec289-e01d-41c9-bb89-70fa845d4bf2",
|
||||
Dashlane = "531126d6-e717-415c-9320-3d9aa6981239",
|
||||
OnePassword = "bada5566-a7aa-401f-bd96-45619a55120d",
|
||||
NordPass = "b84e4048-15dc-4dd0-8640-f4f60813c8af",
|
||||
Keeper = "0ea242b4-43c4-4a1b-8b17-dd6d0b6baec6",
|
||||
Sesame = "891494da-2c90-4d31-a9cd-4eab0aed1309",
|
||||
Enpass = "f3809540-7f14-49c1-a8b3-8f813b225541",
|
||||
ChromiumBrowser = "b5397666-4885-aa6b-cebf-e52262a439a2",
|
||||
EdgeOnMac = "771b48fd-d3d4-4f74-9232-fc157ab0507a",
|
||||
IDmelon = "39a5647e-1853-446c-a1f6-a79bae9f5bc7",
|
||||
Bitwarden = "d548826e-79b4-db40-a3d8-11116f7e8349",
|
||||
ApplePasswords = "fbfc3007-154e-4ecc-8c0b-6e020557d7bd",
|
||||
SamsungPass = "53414d53-554e-4700-0000-000000000000",
|
||||
ThalesBioIOS = "66a0ccb3-bd6a-191f-ee06-e375c50b9846",
|
||||
ThalesBioAndroid = "8836336a-f590-0921-301d-46427531eee6",
|
||||
ThalesPINAndroid = "cd69adb5-3c7a-deb9-3177-6800ea6cb72a",
|
||||
ThalesPINIOS = "17290f1e-c212-34d0-1423-365d729f09d9",
|
||||
ProtonPass = "50726f74-6f6e-5061-7373-50726f746f6e",
|
||||
KeePassXC = "fdb141b2-5d84-443e-8a35-4698c205a502",
|
||||
KeePassDX = "eaecdef2-1c31-5634-8639-f1cbd9c00a08",
|
||||
ToothPicPasskeyProvider = "cc45f64e-52a2-451b-831a-4edd8022a202",
|
||||
IPasswords = "bfc748bb-3429-4faa-b9f9-7cfa9f3b76d0",
|
||||
ZohoVault = "b35a26b2-8f6e-4697-ab1d-d44db4da28c6",
|
||||
LastPass = "b78a0a55-6ef8-d246-a042-ba0f6d55050c",
|
||||
Devolutions = "de503f9c-21a4-4f76-b4b7-558eb55c6f89",
|
||||
LogMeOnce = "22248c4c-7a12-46e2-9a41-44291b373a4d",
|
||||
KasperskyPasswordManager = "a10c6dd9-465e-4226-8198-c7c44b91c555",
|
||||
PwSafe = "d350af52-0351-4ba2-acd3-dfeeadc3f764",
|
||||
MicrosoftPasswordManager = "d3452668-01fd-4c12-926c-83a4204853aa",
|
||||
Initial = "6d212b28-a2c1-4638-b375-5932070f62e9",
|
||||
HeimlaneVault = "d49b2120-b865-4191-8cea-be84a52b0485",
|
||||
}
|
||||
|
||||
export const PasskeyBrandNames: Record<PasskeyBrand, string> = {
|
||||
[PasskeyBrand.AliasVault]: "AliasVault",
|
||||
[PasskeyBrand.GooglePasswordManager]: "Google Password Manager",
|
||||
[PasskeyBrand.ChromeOnMac]: "Chrome on Mac",
|
||||
[PasskeyBrand.WindowsHello1]: "Windows Hello",
|
||||
[PasskeyBrand.WindowsHello2]: "Windows Hello",
|
||||
[PasskeyBrand.WindowsHello3]: "Windows Hello",
|
||||
[PasskeyBrand.ICloudKeychainManaged]: "iCloud Keychain (Managed)",
|
||||
[PasskeyBrand.Dashlane]: "Dashlane",
|
||||
[PasskeyBrand.OnePassword]: "1Password",
|
||||
[PasskeyBrand.NordPass]: "NordPass",
|
||||
[PasskeyBrand.Keeper]: "Keeper",
|
||||
[PasskeyBrand.Sesame]: "Sésame",
|
||||
[PasskeyBrand.Enpass]: "Enpass",
|
||||
[PasskeyBrand.ChromiumBrowser]: "Chromium Browser",
|
||||
[PasskeyBrand.EdgeOnMac]: "Edge on Mac",
|
||||
[PasskeyBrand.IDmelon]: "IDmelon",
|
||||
[PasskeyBrand.Bitwarden]: "Bitwarden",
|
||||
[PasskeyBrand.ApplePasswords]: "Apple Passwords",
|
||||
[PasskeyBrand.SamsungPass]: "Samsung Pass",
|
||||
[PasskeyBrand.ThalesBioIOS]: "Thales Bio iOS SDK",
|
||||
[PasskeyBrand.ThalesBioAndroid]: "Thales Bio Android SDK",
|
||||
[PasskeyBrand.ThalesPINAndroid]: "Thales PIN Android SDK",
|
||||
[PasskeyBrand.ThalesPINIOS]: "Thales PIN iOS SDK",
|
||||
[PasskeyBrand.ProtonPass]: "Proton Pass",
|
||||
[PasskeyBrand.KeePassXC]: "KeePassXC",
|
||||
[PasskeyBrand.KeePassDX]: "KeePassDX",
|
||||
[PasskeyBrand.ToothPicPasskeyProvider]: "ToothPic Passkey Provider",
|
||||
[PasskeyBrand.IPasswords]: "iPasswords",
|
||||
[PasskeyBrand.ZohoVault]: "Zoho Vault",
|
||||
[PasskeyBrand.LastPass]: "LastPass",
|
||||
[PasskeyBrand.Devolutions]: "Devolutions",
|
||||
[PasskeyBrand.LogMeOnce]: "LogMeOnce",
|
||||
[PasskeyBrand.KasperskyPasswordManager]: "Kaspersky Password Manager",
|
||||
[PasskeyBrand.PwSafe]: "pwSafe",
|
||||
[PasskeyBrand.MicrosoftPasswordManager]: "Microsoft Password Manager",
|
||||
[PasskeyBrand.Initial]: "initial",
|
||||
[PasskeyBrand.HeimlaneVault]: "Heimlane Vault",
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a user agent string to extract browser, OS, and device information.
|
||||
*
|
||||
* @param userAgent the user agent string to parse.
|
||||
* @returns parsed information about the browser, OS, and device.
|
||||
*/
|
||||
function parseUserAgent(userAgent: string): ParsedUserAgent {
|
||||
if (!userAgent) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const ua = userAgent.toLowerCase();
|
||||
const result: ParsedUserAgent = {};
|
||||
|
||||
// Detect browser
|
||||
if (ua.includes("edg/") || ua.includes("edga/") || ua.includes("edgios/")) {
|
||||
result.browser = "Edge";
|
||||
} else if (ua.includes("opr/") || ua.includes("opera")) {
|
||||
result.browser = "Opera";
|
||||
} else if (ua.includes("chrome") && !ua.includes("edg")) {
|
||||
result.browser = "Chrome";
|
||||
} else if (ua.includes("safari") && !ua.includes("chrome")) {
|
||||
result.browser = "Safari";
|
||||
} else if (ua.includes("firefox")) {
|
||||
result.browser = "Firefox";
|
||||
}
|
||||
|
||||
// Detect OS (check iPhone/iPad before macOS since they contain "Mac OS X")
|
||||
if (ua.includes("windows")) {
|
||||
result.os = "Windows";
|
||||
} else if (ua.includes("iphone")) {
|
||||
result.os = "iOS";
|
||||
result.device = "iPhone";
|
||||
} else if (ua.includes("ipad")) {
|
||||
result.os = "iOS";
|
||||
result.device = "iPad";
|
||||
} else if (ua.includes("mac os x") || ua.includes("macos")) {
|
||||
result.os = "macOS";
|
||||
} else if (ua.includes("android")) {
|
||||
result.os = "Android";
|
||||
if (ua.includes("mobile")) {
|
||||
result.device = "Android Phone";
|
||||
} else {
|
||||
result.device = "Android Tablet";
|
||||
}
|
||||
} else if (ua.includes("linux")) {
|
||||
result.os = "Linux";
|
||||
} else if (ua.includes("cros")) {
|
||||
result.os = "Chrome OS";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the type of authenticator based on transports.
|
||||
*
|
||||
* @param transports array of authenticator transports.
|
||||
* @returns a human-readable authenticator type or undefined.
|
||||
*/
|
||||
function getAuthenticatorType(transports?: string[]): string | undefined {
|
||||
if (!transports || transports.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Platform authenticators typically use "internal"
|
||||
if (transports.includes("internal")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Security keys typically use USB, NFC, or BLE
|
||||
if (
|
||||
transports.includes("usb") ||
|
||||
transports.includes("nfc") ||
|
||||
transports.includes("ble")
|
||||
) {
|
||||
return "Security Key";
|
||||
}
|
||||
|
||||
// Hybrid authenticators use hybrid transport (phone as authenticator)
|
||||
if (transports.includes("hybrid")) {
|
||||
return "Phone";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a default passkey name when user agent parsing fails.
|
||||
*
|
||||
* @param transports optional array of authenticator transports.
|
||||
* @returns a default passkey name.
|
||||
*/
|
||||
function generateDefaultName(transports?: string[]): string {
|
||||
const authenticatorType = getAuthenticatorType(transports);
|
||||
|
||||
if (authenticatorType) {
|
||||
return authenticatorType;
|
||||
}
|
||||
|
||||
return "Passkey";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a friendly name for a passkey based on AAGUID, user agent, and transport information.
|
||||
*
|
||||
* @param aaguid the authenticator AAGUID from the registration response.
|
||||
* @param userAgent the user agent string from the registration request.
|
||||
* @param transports optional array of authenticator transports.
|
||||
* @returns a human-readable name for the passkey.
|
||||
*/
|
||||
export function generatePasskeyName(
|
||||
aaguid?: string | PasskeyBrand,
|
||||
userAgent?: string,
|
||||
transports?: string[]
|
||||
): string {
|
||||
// Prefer known brand from AAGUID over user agent sniffing
|
||||
if (aaguid && PasskeyBrandNames[aaguid as PasskeyBrand]) {
|
||||
return PasskeyBrandNames[aaguid as PasskeyBrand];
|
||||
}
|
||||
|
||||
// Fall back to user agent parsing if AAGUID is not recognized
|
||||
if (!userAgent) {
|
||||
return generateDefaultName(transports);
|
||||
}
|
||||
|
||||
const parsed = parseUserAgent(userAgent);
|
||||
const parts: string[] = [];
|
||||
|
||||
// Prioritize device name if available (e.g., "iPhone", "iPad")
|
||||
if (parsed.device) {
|
||||
parts.push(parsed.device);
|
||||
} else {
|
||||
// Otherwise use browser and OS
|
||||
if (parsed.browser) {
|
||||
parts.push(parsed.browser);
|
||||
}
|
||||
if (parsed.os) {
|
||||
parts.push(`on ${parsed.os}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add authenticator type hint if available
|
||||
const authenticatorType = getAuthenticatorType(transports);
|
||||
if (authenticatorType) {
|
||||
parts.push(`(${authenticatorType})`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return generateDefaultName(transports);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
@@ -104,6 +104,11 @@ export const RevisionValidation = {
|
||||
maxNameLength: 255,
|
||||
};
|
||||
|
||||
export const UserPasskeyValidation = {
|
||||
minNameLength: 1,
|
||||
maxNameLength: 255,
|
||||
};
|
||||
|
||||
export const PinValidation = {
|
||||
/** The maximum number of pinned documents on an individual collection or home screen */
|
||||
max: 8,
|
||||
|
||||
@@ -3549,6 +3549,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@hexagon/base64@npm:^1.1.27":
|
||||
version: 1.1.28
|
||||
resolution: "@hexagon/base64@npm:1.1.28"
|
||||
checksum: 10c0/f90876938cda7c369444f9abcf268a9d93fe26269ae9a16a95fa1f294a5e8f9d172a77905fac205d315b4538ab4da90c866ba73cc035cc0fcf5a6062bb6691ca
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@hocuspocus/common@npm:^1.1.2":
|
||||
version: 1.1.3
|
||||
resolution: "@hocuspocus/common@npm:1.1.3"
|
||||
@@ -4368,6 +4375,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@levischuck/tiny-cbor@npm:^0.2.2":
|
||||
version: 0.2.11
|
||||
resolution: "@levischuck/tiny-cbor@npm:0.2.11"
|
||||
checksum: 10c0/b34b4b2df5d601f0b260f2cae012168439e6128963959a716ea264586d621d4c411a120219a6abd8c96482de6f11cb34e77365fb6d9f61d99a89c1db5f43ddf3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@lifeomic/attempt@npm:^3.0.2":
|
||||
version: 3.1.0
|
||||
resolution: "@lifeomic/attempt@npm:3.1.0"
|
||||
@@ -5176,6 +5190,162 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@peculiar/asn1-android@npm:^2.3.10":
|
||||
version: 2.5.0
|
||||
resolution: "@peculiar/asn1-android@npm:2.5.0"
|
||||
dependencies:
|
||||
"@peculiar/asn1-schema": "npm:^2.5.0"
|
||||
asn1js: "npm:^3.0.6"
|
||||
tslib: "npm:^2.8.1"
|
||||
checksum: 10c0/06c5ac26542886a5254ebb58c6c0f31e1fe355881a32c8e724adb46b16dc7312dcaba9d430e31df48239a3d92e176ae2673872077d5fb5cf17cd0e4901b7e22a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@peculiar/asn1-cms@npm:^2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@peculiar/asn1-cms@npm:2.5.0"
|
||||
dependencies:
|
||||
"@peculiar/asn1-schema": "npm:^2.5.0"
|
||||
"@peculiar/asn1-x509": "npm:^2.5.0"
|
||||
"@peculiar/asn1-x509-attr": "npm:^2.5.0"
|
||||
asn1js: "npm:^3.0.6"
|
||||
tslib: "npm:^2.8.1"
|
||||
checksum: 10c0/4e7b7d9ab67f628d8d9ea49001c1a377c44e50e5c75d8472a49a2e757f34de629b9ae00ee5b1ab974f4c401f6a827d1787ac7eb63e1a3d2ec18d7015ba551739
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@peculiar/asn1-csr@npm:^2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@peculiar/asn1-csr@npm:2.5.0"
|
||||
dependencies:
|
||||
"@peculiar/asn1-schema": "npm:^2.5.0"
|
||||
"@peculiar/asn1-x509": "npm:^2.5.0"
|
||||
asn1js: "npm:^3.0.6"
|
||||
tslib: "npm:^2.8.1"
|
||||
checksum: 10c0/30d19363f34e073bca034db2106fa3f9c6981bc3f1da7b47dcebc463e491a18459efe0e7b771eed82c64d97636c70a94cb16112492b88346d2d9946f4ebf274e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@peculiar/asn1-ecc@npm:^2.3.8, @peculiar/asn1-ecc@npm:^2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@peculiar/asn1-ecc@npm:2.5.0"
|
||||
dependencies:
|
||||
"@peculiar/asn1-schema": "npm:^2.5.0"
|
||||
"@peculiar/asn1-x509": "npm:^2.5.0"
|
||||
asn1js: "npm:^3.0.6"
|
||||
tslib: "npm:^2.8.1"
|
||||
checksum: 10c0/447c4a03e45ecfbd9775704d9cec594fbdb234a2280e26373284d7959c3b15a216241b7bf85262e78775dd8509689a64d7e783d44745a8b2fe68863c31315985
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@peculiar/asn1-pfx@npm:^2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@peculiar/asn1-pfx@npm:2.5.0"
|
||||
dependencies:
|
||||
"@peculiar/asn1-cms": "npm:^2.5.0"
|
||||
"@peculiar/asn1-pkcs8": "npm:^2.5.0"
|
||||
"@peculiar/asn1-rsa": "npm:^2.5.0"
|
||||
"@peculiar/asn1-schema": "npm:^2.5.0"
|
||||
asn1js: "npm:^3.0.6"
|
||||
tslib: "npm:^2.8.1"
|
||||
checksum: 10c0/f1f8db89dfa53b74f59ecc83cb153f3f3c5576c528c0fc484d7c6318e05dc849efaa3c9efa94d147caa7ad7648afce4b5f9cec7346077b50200ef3a4581ddf59
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@peculiar/asn1-pkcs8@npm:^2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@peculiar/asn1-pkcs8@npm:2.5.0"
|
||||
dependencies:
|
||||
"@peculiar/asn1-schema": "npm:^2.5.0"
|
||||
"@peculiar/asn1-x509": "npm:^2.5.0"
|
||||
asn1js: "npm:^3.0.6"
|
||||
tslib: "npm:^2.8.1"
|
||||
checksum: 10c0/f799eafdb3167a3d0409d4aa740a28d67c8aa41ce39b9c73e5ddb8b5934e665a2029ee96d6f09f59980a5374bb14f892e317c7c9d370b78cee5d67c04eacea6f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@peculiar/asn1-pkcs9@npm:^2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@peculiar/asn1-pkcs9@npm:2.5.0"
|
||||
dependencies:
|
||||
"@peculiar/asn1-cms": "npm:^2.5.0"
|
||||
"@peculiar/asn1-pfx": "npm:^2.5.0"
|
||||
"@peculiar/asn1-pkcs8": "npm:^2.5.0"
|
||||
"@peculiar/asn1-schema": "npm:^2.5.0"
|
||||
"@peculiar/asn1-x509": "npm:^2.5.0"
|
||||
"@peculiar/asn1-x509-attr": "npm:^2.5.0"
|
||||
asn1js: "npm:^3.0.6"
|
||||
tslib: "npm:^2.8.1"
|
||||
checksum: 10c0/a12a2c2eb874bfbce23e65b7ebed35abfc329b7ed505854b8b1f54e7ffc6c25638472b289993066acb9723c6af64e4734284ce47cf46b9a00c16d346756ab98d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@peculiar/asn1-rsa@npm:^2.3.8, @peculiar/asn1-rsa@npm:^2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@peculiar/asn1-rsa@npm:2.5.0"
|
||||
dependencies:
|
||||
"@peculiar/asn1-schema": "npm:^2.5.0"
|
||||
"@peculiar/asn1-x509": "npm:^2.5.0"
|
||||
asn1js: "npm:^3.0.6"
|
||||
tslib: "npm:^2.8.1"
|
||||
checksum: 10c0/ca11e9512e27e12760cf4cccd60830f4ac0c06766cbbe52adf9f6384310366e5c5f6da2460da164d140ce6ccee071ceab0ec29646a7953210633ccfd4aff0fd6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@peculiar/asn1-schema@npm:^2.3.8, @peculiar/asn1-schema@npm:^2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@peculiar/asn1-schema@npm:2.5.0"
|
||||
dependencies:
|
||||
asn1js: "npm:^3.0.6"
|
||||
pvtsutils: "npm:^1.3.6"
|
||||
tslib: "npm:^2.8.1"
|
||||
checksum: 10c0/17a3a68b9ac631beeea6fa6a86b5b95e2d91602d6c477f18374beadfbc71fd4cdbec3290233bf8eae0b216595229450b3cff8ba9c7b96b4a56d57cbbd41ff62f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@peculiar/asn1-x509-attr@npm:^2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@peculiar/asn1-x509-attr@npm:2.5.0"
|
||||
dependencies:
|
||||
"@peculiar/asn1-schema": "npm:^2.5.0"
|
||||
"@peculiar/asn1-x509": "npm:^2.5.0"
|
||||
asn1js: "npm:^3.0.6"
|
||||
tslib: "npm:^2.8.1"
|
||||
checksum: 10c0/bbae84dfb676297d137dcf8e67d673ff74a7c26de829e89a338023ec1de8a0759a33c786b7cfb3f7b43c41da4f127c6c9a0a17f0d4dc12495c0457a908a749f0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@peculiar/asn1-x509@npm:^2.3.8, @peculiar/asn1-x509@npm:^2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@peculiar/asn1-x509@npm:2.5.0"
|
||||
dependencies:
|
||||
"@peculiar/asn1-schema": "npm:^2.5.0"
|
||||
asn1js: "npm:^3.0.6"
|
||||
pvtsutils: "npm:^1.3.6"
|
||||
tslib: "npm:^2.8.1"
|
||||
checksum: 10c0/278c81f8a9c025a4276d5d88d955f5374e03cd4181b6aaf4d16ac369f323aff118da531c41de17abd8903c149b4811e3ad457e445ce91ad3d1ac30785d01686e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@peculiar/x509@npm:^1.13.0":
|
||||
version: 1.14.0
|
||||
resolution: "@peculiar/x509@npm:1.14.0"
|
||||
dependencies:
|
||||
"@peculiar/asn1-cms": "npm:^2.5.0"
|
||||
"@peculiar/asn1-csr": "npm:^2.5.0"
|
||||
"@peculiar/asn1-ecc": "npm:^2.5.0"
|
||||
"@peculiar/asn1-pkcs9": "npm:^2.5.0"
|
||||
"@peculiar/asn1-rsa": "npm:^2.5.0"
|
||||
"@peculiar/asn1-schema": "npm:^2.5.0"
|
||||
"@peculiar/asn1-x509": "npm:^2.5.0"
|
||||
pvtsutils: "npm:^1.3.6"
|
||||
reflect-metadata: "npm:^0.2.2"
|
||||
tslib: "npm:^2.8.1"
|
||||
tsyringe: "npm:^4.10.0"
|
||||
checksum: 10c0/87f6da2965bfab0a898f2ed91724db2c171ed40542bc0df281456675f05193f398800d93a012ee2c9d0d4e16217ba37ab951105e17b2f300232044ab2d8ea883
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@pkgjs/parseargs@npm:^0.11.0":
|
||||
version: 0.11.0
|
||||
resolution: "@pkgjs/parseargs@npm:0.11.0"
|
||||
@@ -6643,6 +6813,29 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@simplewebauthn/browser@npm:^13.2.2":
|
||||
version: 13.2.2
|
||||
resolution: "@simplewebauthn/browser@npm:13.2.2"
|
||||
checksum: 10c0/07cd7b71fbe975504c2de2d81b5e3d0174ce495e51795af002ff0f38cc55fd58d023abd914c35477d4bb28952de385d9948b7e2d872343ebadcbcdb9ddc05e1c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@simplewebauthn/server@npm:^13.2.2":
|
||||
version: 13.2.2
|
||||
resolution: "@simplewebauthn/server@npm:13.2.2"
|
||||
dependencies:
|
||||
"@hexagon/base64": "npm:^1.1.27"
|
||||
"@levischuck/tiny-cbor": "npm:^0.2.2"
|
||||
"@peculiar/asn1-android": "npm:^2.3.10"
|
||||
"@peculiar/asn1-ecc": "npm:^2.3.8"
|
||||
"@peculiar/asn1-rsa": "npm:^2.3.8"
|
||||
"@peculiar/asn1-schema": "npm:^2.3.8"
|
||||
"@peculiar/asn1-x509": "npm:^2.3.8"
|
||||
"@peculiar/x509": "npm:^1.13.0"
|
||||
checksum: 10c0/4dbcf25fe9b33ae37add96f3c8be3489eee8cf4126f9dab0f6637c4f6c1fabc9ac3b2d337452be3e6c9908616322eb07c7f782d316c39c6f6a9567f01c6241c6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sinclair/typebox@npm:^0.27.8":
|
||||
version: 0.27.8
|
||||
resolution: "@sinclair/typebox@npm:0.27.8"
|
||||
@@ -9302,6 +9495,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"asn1js@npm:^3.0.6":
|
||||
version: 3.0.6
|
||||
resolution: "asn1js@npm:3.0.6"
|
||||
dependencies:
|
||||
pvtsutils: "npm:^1.3.6"
|
||||
pvutils: "npm:^1.1.3"
|
||||
tslib: "npm:^2.8.1"
|
||||
checksum: 10c0/96d35e65e3df819ad9cc2d91d1150a3041fd84687a62faa73405e72a6b4c655bc2450e779fad524969e14eeac1f69db2559f27ef6d06ddeeddada28f72ad9b89
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-function@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "async-function@npm:1.0.0"
|
||||
@@ -17467,12 +17671,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"outline-icons@npm:^3.17.0":
|
||||
version: 3.17.0
|
||||
resolution: "outline-icons@npm:3.17.0"
|
||||
"outline-icons@npm:^3.18.0":
|
||||
version: 3.18.0
|
||||
resolution: "outline-icons@npm:3.18.0"
|
||||
peerDependencies:
|
||||
react: ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/9d2d293d830d77ce3865693734b1c4f9669a70ec21f04f7a3ebf0393c513525117fbe79cf691cb22e4232a8a579f5aa6fa794f6da506862bad0eab45f84d07bd
|
||||
checksum: 10c0/af7c3c8766294de7eedd3c202488f02462e9db96914759c23ccc58ab66bfe6b1a0469eefd590992e684f54fc968fd27a0658e29113b3963f853a5a693deb1702
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -17538,6 +17742,8 @@ __metadata:
|
||||
"@relative-ci/agent": "npm:^4.3.1"
|
||||
"@sentry/node": "npm:^7.120.4"
|
||||
"@sentry/react": "npm:^7.120.4"
|
||||
"@simplewebauthn/browser": "npm:^13.2.2"
|
||||
"@simplewebauthn/server": "npm:^13.2.2"
|
||||
"@tanstack/react-table": "npm:^8.21.3"
|
||||
"@tanstack/react-virtual": "npm:^3.13.12"
|
||||
"@types/addressparser": "npm:^1.0.3"
|
||||
@@ -17695,7 +17901,7 @@ __metadata:
|
||||
nodemailer: "npm:^7.0.11"
|
||||
nodemon: "npm:^3.1.11"
|
||||
octokit: "npm:^3.2.2"
|
||||
outline-icons: "npm:^3.17.0"
|
||||
outline-icons: "npm:^3.18.0"
|
||||
oxlint: "npm:1.11.2"
|
||||
oxlint-tsgolint: "npm:^0.1.6"
|
||||
oy-vey: "npm:^0.12.1"
|
||||
@@ -18982,6 +19188,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pvtsutils@npm:^1.3.6":
|
||||
version: 1.3.6
|
||||
resolution: "pvtsutils@npm:1.3.6"
|
||||
dependencies:
|
||||
tslib: "npm:^2.8.1"
|
||||
checksum: 10c0/b1b42646370505ccae536dcffa662303b2c553995211330c8e39dec9ab8c197585d7751c2c5b9ab2f186feda0219d9bb23c34ee1e565573be96450f79d89a13c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pvutils@npm:^1.1.3":
|
||||
version: 1.1.5
|
||||
resolution: "pvutils@npm:1.1.5"
|
||||
checksum: 10c0/e968b07b78a58fec9377fe7aa6342c8cfa21c8fb4afc4e51e1489bd42bec6dc71b8a52541d0aede0aea17adec7ca3f89f29f56efdc31d0083cc02e9bb5721bcf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"qs@npm:6.14.1":
|
||||
version: 6.14.1
|
||||
resolution: "qs@npm:6.14.1"
|
||||
@@ -21600,13 +21822,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.6.2":
|
||||
"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1":
|
||||
version: 2.8.1
|
||||
resolution: "tslib@npm:2.8.1"
|
||||
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tslib@npm:^1.9.3":
|
||||
version: 1.14.1
|
||||
resolution: "tslib@npm:1.14.1"
|
||||
checksum: 10c0/69ae09c49eea644bc5ebe1bca4fa4cc2c82b7b3e02f43b84bd891504edf66dbc6b2ec0eef31a957042de2269139e4acff911e6d186a258fb14069cd7f6febce2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tsscmp@npm:1.0.6":
|
||||
version: 1.0.6
|
||||
resolution: "tsscmp@npm:1.0.6"
|
||||
@@ -21614,6 +21843,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tsyringe@npm:^4.10.0":
|
||||
version: 4.10.0
|
||||
resolution: "tsyringe@npm:4.10.0"
|
||||
dependencies:
|
||||
tslib: "npm:^1.9.3"
|
||||
checksum: 10c0/918594b4dfac97beb8be2c041c6ec45f078ef3768ed4edfe35ae2c709ab503e2e6b454b2b37e692c658572d1972a428fbfdbc0a2b42fee727a83c1c685fbe5e1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ttl-set@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "ttl-set@npm:1.0.0"
|
||||
|
||||
Reference in New Issue
Block a user