Merge branch 'main' into ENG-4013

This commit is contained in:
x032205
2026-01-06 16:16:01 -05:00
97 changed files with 3980 additions and 830 deletions

View File

@@ -41,6 +41,10 @@ jobs:
node-version: "20" node-version: "20"
cache: "npm" cache: "npm"
cache-dependency-path: backend/package-lock.json cache-dependency-path: backend/package-lock.json
- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
working-directory: backend working-directory: backend

View File

@@ -140,6 +140,33 @@ RUN apt-get update && apt-get install -y \
openssh-client \ openssh-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Oracle Instant Client for OracleDB mTLS wallet support
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
ORACLE_ZIP="instantclient-basic-linux.x64-23.26.0.0.0.zip" && \
ORACLE_URL="https://download.oracle.com/otn_software/linux/instantclient/2326000/${ORACLE_ZIP}" && \
EXPECTED_SHA="d6c79cbcf0ff209363e779855c690d4fc730aed847e9198a2c439bcf34760af5" && \
apt-get update && apt-get install -y libaio1t64 unzip && \
ln -sf /lib/x86_64-linux-gnu/libaio.so.1t64 /lib/x86_64-linux-gnu/libaio.so.1 && \
wget -q "$ORACLE_URL" && \
echo "$EXPECTED_SHA $ORACLE_ZIP" | sha256sum -c - && \
unzip "$ORACLE_ZIP" -d /opt/oracle && \
rm "$ORACLE_ZIP"; \
elif [ "$ARCH" = "arm64" ]; then \
ORACLE_ZIP="instantclient-basic-linux.arm64-23.26.0.0.0.zip" && \
ORACLE_URL="https://download.oracle.com/otn_software/linux/instantclient/2326000/${ORACLE_ZIP}" && \
EXPECTED_SHA="9c9a32051e97f087016fb334b7ad5c0aea8511ca8363afd8e0dc6ec4fc515c32" && \
apt-get update && apt-get install -y libaio1t64 unzip && \
ln -sf /lib/aarch64-linux-gnu/libaio.so.1t64 /lib/aarch64-linux-gnu/libaio.so.1 && \
wget -q "$ORACLE_URL" && \
echo "$EXPECTED_SHA $ORACLE_ZIP" | sha256sum -c - && \
unzip "$ORACLE_ZIP" -d /opt/oracle && \
rm "$ORACLE_ZIP"; \
fi && \
echo /opt/oracle/instantclient_23_26 > /etc/ld.so.conf.d/oracle-instantclient.conf && \
ldconfig && \
rm -rf /var/lib/apt/lists/*
# Configure ODBC in production # Configure ODBC in production
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsS.so\nFileUsage = 1\n" > /etc/odbcinst.ini RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsS.so\nFileUsage = 1\n" > /etc/odbcinst.ini

View File

@@ -137,10 +137,37 @@ RUN apt-get update && apt-get install -y \
unixodbc-dev \ unixodbc-dev \
libc-dev \ libc-dev \
freetds-dev \ freetds-dev \
wget \ wget \
openssh-client \ openssh-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Oracle Instant Client for OracleDB mTLS wallet support
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
ORACLE_ZIP="instantclient-basic-linux.x64-23.26.0.0.0.zip" && \
ORACLE_URL="https://download.oracle.com/otn_software/linux/instantclient/2326000/${ORACLE_ZIP}" && \
EXPECTED_SHA="d6c79cbcf0ff209363e779855c690d4fc730aed847e9198a2c439bcf34760af5" && \
apt-get update && apt-get install -y libaio1t64 unzip && \
ln -sf /lib/x86_64-linux-gnu/libaio.so.1t64 /lib/x86_64-linux-gnu/libaio.so.1 && \
wget -q "$ORACLE_URL" && \
echo "$EXPECTED_SHA $ORACLE_ZIP" | sha256sum -c - && \
unzip "$ORACLE_ZIP" -d /opt/oracle && \
rm "$ORACLE_ZIP"; \
elif [ "$ARCH" = "arm64" ]; then \
ORACLE_ZIP="instantclient-basic-linux.arm64-23.26.0.0.0.zip" && \
ORACLE_URL="https://download.oracle.com/otn_software/linux/instantclient/2326000/${ORACLE_ZIP}" && \
EXPECTED_SHA="9c9a32051e97f087016fb334b7ad5c0aea8511ca8363afd8e0dc6ec4fc515c32" && \
apt-get update && apt-get install -y libaio1t64 unzip && \
ln -sf /lib/aarch64-linux-gnu/libaio.so.1t64 /lib/aarch64-linux-gnu/libaio.so.1 && \
wget -q "$ORACLE_URL" && \
echo "$EXPECTED_SHA $ORACLE_ZIP" | sha256sum -c - && \
unzip "$ORACLE_ZIP" -d /opt/oracle && \
rm "$ORACLE_ZIP"; \
fi && \
echo /opt/oracle/instantclient_23_26 > /etc/ld.so.conf.d/oracle-instantclient.conf && \
ldconfig && \
rm -rf /var/lib/apt/lists/*
# Install Infisical CLI # Install Infisical CLI
RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \ RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \
&& apt-get update && apt-get install -y infisical=0.43.14 \ && apt-get update && apt-get install -y infisical=0.43.14 \

2
backend/.gitignore vendored
View File

@@ -1 +1,3 @@
dist dist
/wallet

View File

@@ -48,6 +48,33 @@ RUN apt-get install -y \
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini
# Install Oracle Instant Client for OracleDB mTLS wallet support
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
ORACLE_ZIP="instantclient-basic-linux.x64-23.26.0.0.0.zip" && \
ORACLE_URL="https://download.oracle.com/otn_software/linux/instantclient/2326000/${ORACLE_ZIP}" && \
EXPECTED_SHA="d6c79cbcf0ff209363e779855c690d4fc730aed847e9198a2c439bcf34760af5" && \
apt-get update && apt-get install -y libaio1t64 unzip wget && \
ln -sf /lib/x86_64-linux-gnu/libaio.so.1t64 /lib/x86_64-linux-gnu/libaio.so.1 && \
wget -q "$ORACLE_URL" && \
echo "$EXPECTED_SHA $ORACLE_ZIP" | sha256sum -c - && \
unzip "$ORACLE_ZIP" -d /opt/oracle && \
rm "$ORACLE_ZIP"; \
elif [ "$ARCH" = "arm64" ]; then \
ORACLE_ZIP="instantclient-basic-linux.arm64-23.26.0.0.0.zip" && \
ORACLE_URL="https://download.oracle.com/otn_software/linux/instantclient/2326000/${ORACLE_ZIP}" && \
EXPECTED_SHA="9c9a32051e97f087016fb334b7ad5c0aea8511ca8363afd8e0dc6ec4fc515c32" && \
apt-get update && apt-get install -y libaio1t64 unzip wget && \
ln -sf /lib/aarch64-linux-gnu/libaio.so.1t64 /lib/aarch64-linux-gnu/libaio.so.1 && \
wget -q "$ORACLE_URL" && \
echo "$EXPECTED_SHA $ORACLE_ZIP" | sha256sum -c - && \
unzip "$ORACLE_ZIP" -d /opt/oracle && \
rm "$ORACLE_ZIP"; \
fi && \
echo /opt/oracle/instantclient_23_26 > /etc/ld.so.conf.d/oracle-instantclient.conf && \
ldconfig && \
rm -rf /var/lib/apt/lists/*
RUN npm ci --only-production && npm cache clean --force RUN npm ci --only-production && npm cache clean --force
COPY --from=build /app . COPY --from=build /app .

View File

@@ -21,7 +21,18 @@ RUN apt-get update && apt-get install -y \
openssh-client \ openssh-client \
openssl \ openssl \
curl \ curl \
pkg-config pkg-config \
unzip
# Install libaio (required for Oracle Instant Client) - architecture-specific for Debian Trixie
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "arm64" ]; then \
apt-get install -y libaio1t64 && \
ln -sf /lib/aarch64-linux-gnu/libaio.so.1t64 /lib/aarch64-linux-gnu/libaio.so.1; \
else \
apt-get install -y libaio1t64 && \
ln -sf /lib/x86_64-linux-gnu/libaio.so.1t64 /lib/x86_64-linux-gnu/libaio.so.1; \
fi
# Install dependencies for TDS driver (required for SAP ASE dynamic secrets) # Install dependencies for TDS driver (required for SAP ASE dynamic secrets)
RUN apt-get install -y \ RUN apt-get install -y \
@@ -49,6 +60,22 @@ RUN rm -fr ${SOFTHSM2_SOURCES}
# Install pkcs11-tool # Install pkcs11-tool
RUN apt-get install -y opensc RUN apt-get install -y opensc
# Install Oracle Instant Client for OracleDB mTLS (Wallet) support
RUN mkdir -p /opt/oracle && \
ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "arm64" ]; then \
EXPECTED_SHA="9c9a32051e97f087016fb334b7ad5c0aea8511ca8363afd8e0dc6ec4fc515c32" && \
curl -o /tmp/instantclient.zip https://download.oracle.com/otn_software/linux/instantclient/2326000/instantclient-basic-linux.arm64-23.26.0.0.0.zip; \
else \
EXPECTED_SHA="d6c79cbcf0ff209363e779855c690d4fc730aed847e9198a2c439bcf34760af5" && \
curl -o /tmp/instantclient.zip https://download.oracle.com/otn_software/linux/instantclient/2326000/instantclient-basic-linux.x64-23.26.0.0.0.zip; \
fi && \
echo "$EXPECTED_SHA /tmp/instantclient.zip" | sha256sum -c - && \
unzip -oq /tmp/instantclient.zip -d /opt/oracle && \
rm /tmp/instantclient.zip && \
echo /opt/oracle/instantclient_23_26 > /etc/ld.so.conf.d/oracle-instantclient.conf && \
ldconfig
# ? App setup # ? App setup
# Install Infisical CLI # Install Infisical CLI

View File

@@ -22,7 +22,17 @@ RUN apt-get update && apt-get install -y \
curl \ curl \
pkg-config \ pkg-config \
perl \ perl \
wget wget \
unzip
# Install libaio (required for Oracle Instant Client) - architecture-specific for Debian Trixie
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "arm64" ]; then \
apt-get install -y libaio1t64 && \
ln -sf /lib/aarch64-linux-gnu/libaio.so.1t64 /lib/aarch64-linux-gnu/libaio.so.1; \
else \
apt-get install -y libaio1t64; \
fi
# Install dependencies for TDS driver (required for SAP ASE dynamic secrets) # Install dependencies for TDS driver (required for SAP ASE dynamic secrets)
RUN apt-get install -y \ RUN apt-get install -y \
@@ -50,6 +60,22 @@ RUN rm -fr ${SOFTHSM2_SOURCES}
# Install pkcs11-tool # Install pkcs11-tool
RUN apt-get install -y opensc RUN apt-get install -y opensc
# Install Oracle Instant Client for OracleDB mTLS (Wallet) support
RUN mkdir -p /opt/oracle && \
ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "arm64" ]; then \
EXPECTED_SHA="9c9a32051e97f087016fb334b7ad5c0aea8511ca8363afd8e0dc6ec4fc515c32" && \
curl -o /tmp/instantclient.zip https://download.oracle.com/otn_software/linux/instantclient/2326000/instantclient-basic-linux.arm64-23.26.0.0.0.zip; \
else \
EXPECTED_SHA="d6c79cbcf0ff209363e779855c690d4fc730aed847e9198a2c439bcf34760af5" && \
curl -o /tmp/instantclient.zip https://download.oracle.com/otn_software/linux/instantclient/2326000/instantclient-basic-linux.x64-23.26.0.0.0.zip; \
fi && \
echo "$EXPECTED_SHA /tmp/instantclient.zip" | sha256sum -c - && \
unzip -oq /tmp/instantclient.zip -d /opt/oracle && \
rm /tmp/instantclient.zip && \
echo /opt/oracle/instantclient_23_26 > /etc/ld.so.conf.d/oracle-instantclient.conf && \
ldconfig
WORKDIR /openssl-build WORKDIR /openssl-build
RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \
&& tar -xf openssl-3.1.2.tar.gz \ && tar -xf openssl-3.1.2.tar.gz \

View File

@@ -56,6 +56,7 @@
"@peculiar/x509": "^1.12.1", "@peculiar/x509": "^1.12.1",
"@react-email/components": "^1.0.1", "@react-email/components": "^1.0.1",
"@serdnam/pino-cloudwatch-transport": "^1.0.4", "@serdnam/pino-cloudwatch-transport": "^1.0.4",
"@simplewebauthn/server": "^13.2.2",
"@sindresorhus/slugify": "1.1.0", "@sindresorhus/slugify": "1.1.0",
"@slack/oauth": "^3.0.2", "@slack/oauth": "^3.0.2",
"@slack/web-api": "^7.8.0", "@slack/web-api": "^7.8.0",
@@ -157,6 +158,7 @@
"@types/lodash.isequal": "^4.5.8", "@types/lodash.isequal": "^4.5.8",
"@types/node": "^20.19.0", "@types/node": "^20.19.0",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/oracledb": "^6.10.0",
"@types/passport-google-oauth20": "^2.0.14", "@types/passport-google-oauth20": "^2.0.14",
"@types/pg": "^8.10.9", "@types/pg": "^8.10.9",
"@types/picomatch": "^2.3.3", "@types/picomatch": "^2.3.3",
@@ -662,7 +664,6 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.637.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.637.0.tgz",
"integrity": "sha512-xUi7x4qDubtA8QREtlblPuAcn91GS/09YVEY/RwU7xCY0aqGuFwgszAANlha4OUIqva8oVj2WO4gJuG+iaSnhw==", "integrity": "sha512-xUi7x4qDubtA8QREtlblPuAcn91GS/09YVEY/RwU7xCY0aqGuFwgszAANlha4OUIqva8oVj2WO4gJuG+iaSnhw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0", "@aws-crypto/sha256-js": "5.2.0",
@@ -2109,7 +2110,6 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.682.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.682.0.tgz",
"integrity": "sha512-ZPZ7Y/r/w3nx/xpPzGSqSQsB090Xk5aZZOH+WBhTDn/pBEuim09BYXCLzvvxb7R7NnuoQdrTJiwimdJAhHl7ZQ==", "integrity": "sha512-ZPZ7Y/r/w3nx/xpPzGSqSQsB090Xk5aZZOH+WBhTDn/pBEuim09BYXCLzvvxb7R7NnuoQdrTJiwimdJAhHl7ZQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0", "@aws-crypto/sha256-js": "5.2.0",
@@ -2163,7 +2163,6 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.682.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.682.0.tgz",
"integrity": "sha512-xKuo4HksZ+F8m9DOfx/ZuWNhaPuqZFPwwy0xqcBT6sWH7OAuBjv/fnpOTzyQhpVTWddlf+ECtMAMrxjxuOExGQ==", "integrity": "sha512-xKuo4HksZ+F8m9DOfx/ZuWNhaPuqZFPwwy0xqcBT6sWH7OAuBjv/fnpOTzyQhpVTWddlf+ECtMAMrxjxuOExGQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0", "@aws-crypto/sha256-js": "5.2.0",
@@ -2663,7 +2662,6 @@
"version": "3.632.0", "version": "3.632.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.632.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.632.0.tgz",
"integrity": "sha512-Oh1fIWaoZluihOCb/zDEpRTi+6an82fgJz7fyRBugyLhEtDjmvpCQ3oKjzaOhoN+4EvXAm1ZS/ZgpvXBlIRTgw==", "integrity": "sha512-Oh1fIWaoZluihOCb/zDEpRTi+6an82fgJz7fyRBugyLhEtDjmvpCQ3oKjzaOhoN+4EvXAm1ZS/ZgpvXBlIRTgw==",
"peer": true,
"dependencies": { "dependencies": {
"@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0", "@aws-crypto/sha256-js": "5.2.0",
@@ -2740,7 +2738,6 @@
"version": "3.632.0", "version": "3.632.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.632.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.632.0.tgz",
"integrity": "sha512-Ss5cBH09icpTvT+jtGGuQlRdwtO7RyE9BF4ZV/CEPATdd9whtJt4Qxdya8BUnkWR7h5HHTrQHqai3YVYjku41A==", "integrity": "sha512-Ss5cBH09icpTvT+jtGGuQlRdwtO7RyE9BF4ZV/CEPATdd9whtJt4Qxdya8BUnkWR7h5HHTrQHqai3YVYjku41A==",
"peer": true,
"dependencies": { "dependencies": {
"@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0", "@aws-crypto/sha256-js": "5.2.0",
@@ -5178,7 +5175,6 @@
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.0", "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2", "@babel/code-frame": "^7.26.2",
@@ -7210,7 +7206,6 @@
"url": "https://opencollective.com/csstools" "url": "https://opencollective.com/csstools"
} }
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -7232,7 +7227,6 @@
"url": "https://opencollective.com/csstools" "url": "https://opencollective.com/csstools"
} }
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8560,6 +8554,12 @@
"resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.1.0.tgz",
"integrity": "sha512-i1BpaNDVLJdRBEKeJWkVO6tYX6DMFBuwMhSuWqLsY4ufeTKGVuV5rBsUhxPayXqnnWHgXUAmWK16H/ykO5Wj4Q==" "integrity": "sha512-i1BpaNDVLJdRBEKeJWkVO6tYX6DMFBuwMhSuWqLsY4ufeTKGVuV5rBsUhxPayXqnnWHgXUAmWK16H/ykO5Wj4Q=="
}, },
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.13", "version": "0.11.13",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
@@ -9213,9 +9213,6 @@
"win32" "win32"
] ]
}, },
"node_modules/@infisical/quic/node_modules/@infisical/quic-linux-arm": {
"optional": true
},
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
@@ -9427,6 +9424,12 @@
"resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz",
"integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==" "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ=="
}, },
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@lukeed/ms": { "node_modules/@lukeed/ms": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
@@ -9959,6 +9962,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -10665,7 +10669,6 @@
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz", "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz",
"integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@octokit/auth-token": "^4.0.0", "@octokit/auth-token": "^4.0.0",
"@octokit/graphql": "^7.1.0", "@octokit/graphql": "^7.1.0",
@@ -11109,7 +11112,6 @@
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"peer": true,
"engines": { "engines": {
"node": ">=8.0.0" "node": ">=8.0.0"
} }
@@ -11428,147 +11430,160 @@
"@otplib/plugin-thirty-two": "^12.0.1" "@otplib/plugin-thirty-two": "^12.0.1"
} }
}, },
"node_modules/@peculiar/asn1-cms": { "node_modules/@peculiar/asn1-android": {
"version": "2.3.8", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz",
"integrity": "sha512-Wtk9R7yQxGaIaawHorWKP2OOOm/RZzamOmSWwaqGphIuU6TcKYih0slL6asZlSSZtVoYTrBfrddSOD/jTu9vuQ==", "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.3.8", "asn1js": "^3.0.6",
"@peculiar/asn1-x509-attr": "^2.3.8", "tslib": "^2.8.1"
"asn1js": "^3.0.5", }
"tslib": "^2.6.2" },
"node_modules/@peculiar/asn1-cms": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz",
"integrity": "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"@peculiar/asn1-x509-attr": "^2.6.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
} }
}, },
"node_modules/@peculiar/asn1-csr": { "node_modules/@peculiar/asn1-csr": {
"version": "2.3.8", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.3.8.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz",
"integrity": "sha512-ZmAaP2hfzgIGdMLcot8gHTykzoI+X/S53x1xoGbTmratETIaAbSWMiPGvZmXRA0SNEIydpMkzYtq4fQBxN1u1w==", "integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.3.8", "@peculiar/asn1-x509": "^2.6.0",
"asn1js": "^3.0.5", "asn1js": "^3.0.6",
"tslib": "^2.6.2" "tslib": "^2.8.1"
} }
}, },
"node_modules/@peculiar/asn1-ecc": { "node_modules/@peculiar/asn1-ecc": {
"version": "2.3.8", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.8.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz",
"integrity": "sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==", "integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.3.8", "@peculiar/asn1-x509": "^2.6.0",
"asn1js": "^3.0.5", "asn1js": "^3.0.6",
"tslib": "^2.6.2" "tslib": "^2.8.1"
} }
}, },
"node_modules/@peculiar/asn1-pfx": { "node_modules/@peculiar/asn1-pfx": {
"version": "2.3.8", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.3.8.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz",
"integrity": "sha512-XhdnCVznMmSmgy68B9pVxiZ1XkKoE1BjO4Hv+eUGiY1pM14msLsFZ3N7K46SoITIVZLq92kKkXpGiTfRjlNLyg==", "integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-cms": "^2.3.8", "@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-pkcs8": "^2.3.8", "@peculiar/asn1-pkcs8": "^2.6.0",
"@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.6.0",
"asn1js": "^3.0.5", "asn1js": "^3.0.6",
"tslib": "^2.6.2" "tslib": "^2.8.1"
} }
}, },
"node_modules/@peculiar/asn1-pkcs8": { "node_modules/@peculiar/asn1-pkcs8": {
"version": "2.3.8", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.3.8.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz",
"integrity": "sha512-rL8k2x59v8lZiwLRqdMMmOJ30GHt6yuHISFIuuWivWjAJjnxzZBVzMTQ72sknX5MeTSSvGwPmEFk2/N8+UztFQ==", "integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.3.8", "@peculiar/asn1-x509": "^2.6.0",
"asn1js": "^3.0.5", "asn1js": "^3.0.6",
"tslib": "^2.6.2" "tslib": "^2.8.1"
} }
}, },
"node_modules/@peculiar/asn1-pkcs9": { "node_modules/@peculiar/asn1-pkcs9": {
"version": "2.3.8", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.3.8.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz",
"integrity": "sha512-+nONq5tcK7vm3qdY7ZKoSQGQjhJYMJbwJGbXLFOhmqsFIxEWyQPHyV99+wshOjpOjg0wUSSkEEzX2hx5P6EKeQ==", "integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-cms": "^2.3.8", "@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-pfx": "^2.3.8", "@peculiar/asn1-pfx": "^2.6.0",
"@peculiar/asn1-pkcs8": "^2.3.8", "@peculiar/asn1-pkcs8": "^2.6.0",
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.3.8", "@peculiar/asn1-x509": "^2.6.0",
"@peculiar/asn1-x509-attr": "^2.3.8", "@peculiar/asn1-x509-attr": "^2.6.0",
"asn1js": "^3.0.5", "asn1js": "^3.0.6",
"tslib": "^2.6.2" "tslib": "^2.8.1"
} }
}, },
"node_modules/@peculiar/asn1-rsa": { "node_modules/@peculiar/asn1-rsa": {
"version": "2.3.8", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.8.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz",
"integrity": "sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==", "integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==",
"license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.3.8", "@peculiar/asn1-x509": "^2.6.0",
"asn1js": "^3.0.5", "asn1js": "^3.0.6",
"tslib": "^2.6.2" "tslib": "^2.8.1"
} }
}, },
"node_modules/@peculiar/asn1-schema": { "node_modules/@peculiar/asn1-schema": {
"version": "2.3.8", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
"integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==", "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
"license": "MIT",
"dependencies": { "dependencies": {
"asn1js": "^3.0.5", "asn1js": "^3.0.6",
"pvtsutils": "^1.3.5", "pvtsutils": "^1.3.6",
"tslib": "^2.6.2" "tslib": "^2.8.1"
} }
}, },
"node_modules/@peculiar/asn1-x509": { "node_modules/@peculiar/asn1-x509": {
"version": "2.3.8", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.8.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz",
"integrity": "sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==", "integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.6.0",
"asn1js": "^3.0.5", "asn1js": "^3.0.6",
"ipaddr.js": "^2.1.0", "pvtsutils": "^1.3.6",
"pvtsutils": "^1.3.5", "tslib": "^2.8.1"
"tslib": "^2.6.2"
} }
}, },
"node_modules/@peculiar/asn1-x509-attr": { "node_modules/@peculiar/asn1-x509-attr": {
"version": "2.3.8", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.3.8.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz",
"integrity": "sha512-4Z8mSN95MOuX04Aku9BUyMdsMKtVQUqWnr627IheiWnwFoheUhX3R4Y2zh23M7m80r4/WG8MOAckRKc77IRv6g==", "integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.3.8", "@peculiar/asn1-x509": "^2.6.0",
"asn1js": "^3.0.5", "asn1js": "^3.0.6",
"tslib": "^2.6.2" "tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509/node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
"integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==",
"engines": {
"node": ">= 10"
} }
}, },
"node_modules/@peculiar/x509": { "node_modules/@peculiar/x509": {
"version": "1.12.1", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.12.1.tgz", "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.0.tgz",
"integrity": "sha512-2T9t2viNP9m20mky50igPTpn2ByhHl5NlT6wW4Tp4BejQaQ5XDNZgfsabYwYysLXhChABlgtTCpp2gM3JBZRKA==", "integrity": "sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-cms": "^2.3.8", "@peculiar/asn1-cms": "^2.5.0",
"@peculiar/asn1-csr": "^2.3.8", "@peculiar/asn1-csr": "^2.5.0",
"@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-ecc": "^2.5.0",
"@peculiar/asn1-pkcs9": "^2.3.8", "@peculiar/asn1-pkcs9": "^2.5.0",
"@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-rsa": "^2.5.0",
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.5.0",
"@peculiar/asn1-x509": "^2.3.8", "@peculiar/asn1-x509": "^2.5.0",
"pvtsutils": "^1.3.5", "pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"tslib": "^2.6.2", "tslib": "^2.8.1",
"tsyringe": "^4.8.0" "tsyringe": "^4.10.0"
} }
}, },
"node_modules/@phc/format": { "node_modules/@phc/format": {
@@ -11750,7 +11765,6 @@
"resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.2.0.tgz",
"integrity": "sha512-9GCWmVmKUAoRfloboCd+RKm6X17xn7eGL7HnpAZUnjBXBilWCxsKnLMTC/ixSHDKS/A/057M1Tx6ZUXd89sVBw==", "integrity": "sha512-9GCWmVmKUAoRfloboCd+RKm6X17xn7eGL7HnpAZUnjBXBilWCxsKnLMTC/ixSHDKS/A/057M1Tx6ZUXd89sVBw==",
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"react": "^18.0 || ^19.0 || ^19.0.0-rc" "react": "^18.0 || ^19.0 || ^19.0.0-rc"
} }
@@ -11760,7 +11774,6 @@
"resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.0.tgz",
"integrity": "sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==", "integrity": "sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -11773,7 +11786,6 @@
"resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.0.tgz",
"integrity": "sha512-eIrPW9PIFgDopQU0e/OPpwCW2QWQDtNZDSsiN4sJO8KdMnWWnXJicnRfzrit5rHwFo+Y98i+w/Y5ScnBAFr1dQ==", "integrity": "sha512-eIrPW9PIFgDopQU0e/OPpwCW2QWQDtNZDSsiN4sJO8KdMnWWnXJicnRfzrit5rHwFo+Y98i+w/Y5ScnBAFr1dQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prismjs": "^1.30.0" "prismjs": "^1.30.0"
}, },
@@ -11789,7 +11801,6 @@
"resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.5.tgz", "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.5.tgz",
"integrity": "sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==", "integrity": "sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -11865,7 +11876,6 @@
"resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.15.tgz", "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.15.tgz",
"integrity": "sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==", "integrity": "sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -11899,7 +11909,6 @@
"resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.15.tgz", "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.15.tgz",
"integrity": "sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==", "integrity": "sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -11912,7 +11921,6 @@
"resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.11.tgz", "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.11.tgz",
"integrity": "sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==", "integrity": "sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -11937,7 +11945,6 @@
"resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.11.tgz", "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.11.tgz",
"integrity": "sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==", "integrity": "sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -11950,7 +11957,6 @@
"resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.12.tgz", "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.12.tgz",
"integrity": "sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==", "integrity": "sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -11978,7 +11984,6 @@
"resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.13.tgz", "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.13.tgz",
"integrity": "sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==", "integrity": "sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -12083,7 +12088,6 @@
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.5.tgz", "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.5.tgz",
"integrity": "sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==", "integrity": "sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -12513,6 +12517,25 @@
"split2": "^4.0.0" "split2": "^4.0.0"
} }
}, },
"node_modules/@simplewebauthn/server": {
"version": "13.2.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.2.tgz",
"integrity": "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.3.10",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-rsa": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@peculiar/asn1-x509": "^2.3.8",
"@peculiar/x509": "^1.13.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@sindresorhus/slugify": { "node_modules/@sindresorhus/slugify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-1.1.0.tgz",
@@ -14072,7 +14095,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==", "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -14129,6 +14151,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/oracledb": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-6.10.0.tgz",
"integrity": "sha512-dRaEYKRkJhSSM/uKrscE7zXC5D75JSkBgdye5kSxzTRwrMAUI5V675cD3fqCdMuSOiGo2K9Ng3CjjRI4rzeuAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/passport": { "node_modules/@types/passport": {
"version": "1.0.17", "version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
@@ -14551,7 +14583,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.20.0.tgz",
"integrity": "sha512-bYerPDF/H5v6V76MdMYhjwmwgMA+jlPVqjSDq2cRqMi8bP5sR3Z+RLOiOMad3nsnmDVmn2gAFCyNgh/dIrfP/w==", "integrity": "sha512-bYerPDF/H5v6V76MdMYhjwmwgMA+jlPVqjSDq2cRqMi8bP5sR3Z+RLOiOMad3nsnmDVmn2gAFCyNgh/dIrfP/w==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.20.0", "@typescript-eslint/scope-manager": "6.20.0",
"@typescript-eslint/types": "6.20.0", "@typescript-eslint/types": "6.20.0",
@@ -15216,7 +15247,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -15356,22 +15386,6 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/ajv/node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
@@ -15721,7 +15735,6 @@
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bn.js": "^4.0.0", "bn.js": "^4.0.0",
"inherits": "^2.0.1", "inherits": "^2.0.1",
@@ -15751,13 +15764,14 @@
} }
}, },
"node_modules/asn1js": { "node_modules/asn1js": {
"version": "3.0.5", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz",
"integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", "integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==",
"license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"pvtsutils": "^1.3.2", "pvtsutils": "^1.3.6",
"pvutils": "^1.1.3", "pvutils": "^1.1.3",
"tslib": "^2.4.0" "tslib": "^2.8.1"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -15946,7 +15960,6 @@
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.4", "form-data": "^4.0.4",
@@ -16548,7 +16561,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.9", "baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746", "caniuse-lite": "^1.0.30001746",
@@ -18103,7 +18115,6 @@
"version": "16.4.1", "version": "16.4.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz",
"integrity": "sha512-CjA3y+Dr3FyFDOAMnxZEGtnW9KBR2M0JvvUtXNW+dYJL5ROWxP9DUHCwgFqpMk0OXCc0ljhaNTr2w/kutYIcHQ==", "integrity": "sha512-CjA3y+Dr3FyFDOAMnxZEGtnW9KBR2M0JvvUtXNW+dYJL5ROWxP9DUHCwgFqpMk0OXCc0ljhaNTr2w/kutYIcHQ==",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -18489,7 +18500,6 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -18572,7 +18582,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz",
"integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@@ -18671,7 +18680,6 @@
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
@@ -18760,7 +18768,6 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz",
"integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"array-includes": "^3.1.7", "array-includes": "^3.1.7",
"array.prototype.findlastindex": "^1.2.3", "array.prototype.findlastindex": "^1.2.3",
@@ -19181,6 +19188,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@@ -19242,6 +19250,7 @@
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz",
"integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==", "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cookie": "0.7.2", "cookie": "0.7.2",
"cookie-signature": "1.0.7", "cookie-signature": "1.0.7",
@@ -19259,12 +19268,14 @@
"node_modules/express-session/node_modules/cookie-signature": { "node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"peer": true
}, },
"node_modules/express-session/node_modules/debug": { "node_modules/express-session/node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"peer": true,
"dependencies": { "dependencies": {
"ms": "2.0.0" "ms": "2.0.0"
} }
@@ -19272,7 +19283,8 @@
"node_modules/express-session/node_modules/ms": { "node_modules/express-session/node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"peer": true
}, },
"node_modules/express/node_modules/cookie": { "node_modules/express/node_modules/cookie": {
"version": "0.7.1", "version": "0.7.1",
@@ -25929,6 +25941,7 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -26628,7 +26641,6 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz",
"integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -26971,7 +26983,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -27087,7 +27098,6 @@
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -27459,11 +27469,12 @@
} }
}, },
"node_modules/pvtsutils": { "node_modules/pvtsutils": {
"version": "1.3.5", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.6.1" "tslib": "^2.8.1"
} }
}, },
"node_modules/pvutils": { "node_modules/pvutils": {
@@ -27551,6 +27562,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -27646,7 +27658,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -27656,7 +27667,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@@ -29481,7 +29491,6 @@
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz",
"integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ip-address": "^9.0.5", "ip-address": "^9.0.5",
"smart-buffer": "^4.2.0" "smart-buffer": "^4.2.0"
@@ -30980,7 +30989,6 @@
"integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.25.0", "esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5" "get-tsconfig": "^4.7.5"
@@ -30996,9 +31004,10 @@
} }
}, },
"node_modules/tsyringe": { "node_modules/tsyringe": {
"version": "4.8.0", "version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz", "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==", "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^1.9.3" "tslib": "^1.9.3"
}, },
@@ -31009,7 +31018,8 @@
"node_modules/tsyringe/node_modules/tslib": { "node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
}, },
"node_modules/ttl-set": { "node_modules/ttl-set": {
"version": "1.0.0", "version": "1.0.0",
@@ -31158,7 +31168,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz",
"integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -31199,6 +31208,7 @@
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"peer": true,
"dependencies": { "dependencies": {
"random-bytes": "~1.0.0" "random-bytes": "~1.0.0"
}, },
@@ -31786,7 +31796,6 @@
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -32468,7 +32477,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz",
"integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -102,6 +102,7 @@
"@types/lodash.isequal": "^4.5.8", "@types/lodash.isequal": "^4.5.8",
"@types/node": "^20.19.0", "@types/node": "^20.19.0",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/oracledb": "^6.10.0",
"@types/passport-google-oauth20": "^2.0.14", "@types/passport-google-oauth20": "^2.0.14",
"@types/pg": "^8.10.9", "@types/pg": "^8.10.9",
"@types/picomatch": "^2.3.3", "@types/picomatch": "^2.3.3",
@@ -187,6 +188,7 @@
"@peculiar/x509": "^1.12.1", "@peculiar/x509": "^1.12.1",
"@react-email/components": "^1.0.1", "@react-email/components": "^1.0.1",
"@serdnam/pino-cloudwatch-transport": "^1.0.4", "@serdnam/pino-cloudwatch-transport": "^1.0.4",
"@simplewebauthn/server": "^13.2.2",
"@sindresorhus/slugify": "1.1.0", "@sindresorhus/slugify": "1.1.0",
"@slack/oauth": "^3.0.2", "@slack/oauth": "^3.0.2",
"@slack/web-api": "^7.8.0", "@slack/web-api": "^7.8.0",

View File

@@ -102,6 +102,7 @@ import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/i
import { TMembershipGroupServiceFactory } from "@app/services/membership-group/membership-group-service"; import { TMembershipGroupServiceFactory } from "@app/services/membership-group/membership-group-service";
import { TMembershipIdentityServiceFactory } from "@app/services/membership-identity/membership-identity-service"; import { TMembershipIdentityServiceFactory } from "@app/services/membership-identity/membership-identity-service";
import { TMembershipUserServiceFactory } from "@app/services/membership-user/membership-user-service"; import { TMembershipUserServiceFactory } from "@app/services/membership-user/membership-user-service";
import { TMfaSessionServiceFactory } from "@app/services/mfa-session/mfa-session-service";
import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
import { TNotificationServiceFactory } from "@app/services/notification/notification-service"; import { TNotificationServiceFactory } from "@app/services/notification/notification-service";
import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service"; import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service";
@@ -137,6 +138,7 @@ import { TUpgradePathService } from "@app/services/upgrade-path/upgrade-path-ser
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
import { TUserServiceFactory } from "@app/services/user/user-service"; import { TUserServiceFactory } from "@app/services/user/user-service";
import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service";
import { TWebAuthnServiceFactory } from "@app/services/webauthn/webauthn-service";
import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service"; import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service";
import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service";
@@ -329,6 +331,7 @@ declare module "fastify" {
externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory; externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory;
projectTemplate: TProjectTemplateServiceFactory; projectTemplate: TProjectTemplateServiceFactory;
totp: TTotpServiceFactory; totp: TTotpServiceFactory;
webAuthn: TWebAuthnServiceFactory;
appConnection: TAppConnectionServiceFactory; appConnection: TAppConnectionServiceFactory;
secretSync: TSecretSyncServiceFactory; secretSync: TSecretSyncServiceFactory;
kmip: TKmipServiceFactory; kmip: TKmipServiceFactory;
@@ -355,6 +358,7 @@ declare module "fastify" {
pamResource: TPamResourceServiceFactory; pamResource: TPamResourceServiceFactory;
pamAccount: TPamAccountServiceFactory; pamAccount: TPamAccountServiceFactory;
pamSession: TPamSessionServiceFactory; pamSession: TPamSessionServiceFactory;
mfaSession: TMfaSessionServiceFactory;
upgradePath: TUpgradePathService; upgradePath: TUpgradePathService;
membershipUser: TMembershipUserServiceFactory; membershipUser: TMembershipUserServiceFactory;

View File

@@ -614,6 +614,9 @@ import {
TVaultExternalMigrationConfigs, TVaultExternalMigrationConfigs,
TVaultExternalMigrationConfigsInsert, TVaultExternalMigrationConfigsInsert,
TVaultExternalMigrationConfigsUpdate, TVaultExternalMigrationConfigsUpdate,
TWebauthnCredentials,
TWebauthnCredentialsInsert,
TWebauthnCredentialsUpdate,
TWebhooks, TWebhooks,
TWebhooksInsert, TWebhooksInsert,
TWebhooksUpdate, TWebhooksUpdate,
@@ -1528,6 +1531,11 @@ declare module "knex/types/tables" {
TVaultExternalMigrationConfigsInsert, TVaultExternalMigrationConfigsInsert,
TVaultExternalMigrationConfigsUpdate TVaultExternalMigrationConfigsUpdate
>; >;
[TableName.WebAuthnCredential]: KnexOriginal.CompositeTableType<
TWebauthnCredentials,
TWebauthnCredentialsInsert,
TWebauthnCredentialsUpdate
>;
[TableName.AiMcpServer]: KnexOriginal.CompositeTableType<TAiMcpServers, TAiMcpServersInsert, TAiMcpServersUpdate>; [TableName.AiMcpServer]: KnexOriginal.CompositeTableType<TAiMcpServers, TAiMcpServersInsert, TAiMcpServersUpdate>;
[TableName.AiMcpServerTool]: KnexOriginal.CompositeTableType< [TableName.AiMcpServerTool]: KnexOriginal.CompositeTableType<
TAiMcpServerTools, TAiMcpServerTools,

View File

@@ -0,0 +1,30 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.WebAuthnCredential))) {
await knex.schema.createTable(TableName.WebAuthnCredential, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("userId").notNullable();
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
t.text("credentialId").notNullable(); // base64url encoded credential ID
t.text("publicKey").notNullable(); // base64url encoded public key
t.bigInteger("counter").defaultTo(0).notNullable(); // signature counter for replay protection
t.specificType("transports", "text[]").nullable(); // transport methods
t.string("name").nullable(); // user-friendly name
t.timestamp("lastUsedAt").nullable();
t.timestamps(true, true, true);
t.unique("credentialId"); // credential IDs must be unique across all users
t.index("userId"); // index for fast lookups by user
});
await createOnUpdateTrigger(knex, TableName.WebAuthnCredential);
}
}
export async function down(knex: Knex): Promise<void> {
await dropOnUpdateTrigger(knex, TableName.WebAuthnCredential);
await knex.schema.dropTableIfExists(TableName.WebAuthnCredential);
}

View File

@@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.PamAccount, "requireMfa"))) {
await knex.schema.alterTable(TableName.PamAccount, (t) => {
t.boolean("requireMfa").defaultTo(false);
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.PamAccount, "requireMfa")) {
await knex.schema.alterTable(TableName.PamAccount, (t) => {
t.dropColumn("requireMfa");
});
}
}

View File

@@ -209,5 +209,6 @@ export * from "./user-encryption-keys";
export * from "./user-group-membership"; export * from "./user-group-membership";
export * from "./users"; export * from "./users";
export * from "./vault-external-migration-configs"; export * from "./vault-external-migration-configs";
export * from "./webauthn-credentials";
export * from "./webhooks"; export * from "./webhooks";
export * from "./workflow-integrations"; export * from "./workflow-integrations";

View File

@@ -160,6 +160,7 @@ export enum TableName {
InternalKms = "internal_kms", InternalKms = "internal_kms",
InternalKmsKeyVersion = "internal_kms_key_version", InternalKmsKeyVersion = "internal_kms_key_version",
TotpConfig = "totp_configs", TotpConfig = "totp_configs",
WebAuthnCredential = "webauthn_credentials",
// @depreciated // @depreciated
KmsKeyVersion = "kms_key_versions", KmsKeyVersion = "kms_key_versions",
WorkflowIntegrations = "workflow_integrations", WorkflowIntegrations = "workflow_integrations",

View File

@@ -23,7 +23,8 @@ export const PamAccountsSchema = z.object({
rotationIntervalSeconds: z.number().nullable().optional(), rotationIntervalSeconds: z.number().nullable().optional(),
lastRotatedAt: z.date().nullable().optional(), lastRotatedAt: z.date().nullable().optional(),
rotationStatus: z.string().nullable().optional(), rotationStatus: z.string().nullable().optional(),
encryptedLastRotationMessage: zodBuffer.nullable().optional() encryptedLastRotationMessage: zodBuffer.nullable().optional(),
requireMfa: z.boolean().default(false).nullable().optional()
}); });
export type TPamAccounts = z.infer<typeof PamAccountsSchema>; export type TPamAccounts = z.infer<typeof PamAccountsSchema>;

View File

@@ -0,0 +1,25 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const WebauthnCredentialsSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
credentialId: z.string(),
publicKey: z.string(),
counter: z.coerce.number().default(0),
transports: z.string().array().nullable().optional(),
name: z.string().nullable().optional(),
lastUsedAt: z.date().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TWebauthnCredentials = z.infer<typeof WebauthnCredentialsSchema>;
export type TWebauthnCredentialsInsert = Omit<z.input<typeof WebauthnCredentialsSchema>, TImmutableDBKeys>;
export type TWebauthnCredentialsUpdate = Partial<Omit<z.input<typeof WebauthnCredentialsSchema>, TImmutableDBKeys>>;

View File

@@ -24,6 +24,7 @@ export const registerPamAccountEndpoints = <C extends TPamAccount>({
description?: C["description"]; description?: C["description"];
rotationEnabled?: C["rotationEnabled"]; rotationEnabled?: C["rotationEnabled"];
rotationIntervalSeconds?: C["rotationIntervalSeconds"]; rotationIntervalSeconds?: C["rotationIntervalSeconds"];
requireMfa?: C["requireMfa"];
}>; }>;
updateAccountSchema: z.ZodType<{ updateAccountSchema: z.ZodType<{
credentials?: C["credentials"]; credentials?: C["credentials"];
@@ -31,6 +32,7 @@ export const registerPamAccountEndpoints = <C extends TPamAccount>({
description?: C["description"]; description?: C["description"];
rotationEnabled?: C["rotationEnabled"]; rotationEnabled?: C["rotationEnabled"];
rotationIntervalSeconds?: C["rotationIntervalSeconds"]; rotationIntervalSeconds?: C["rotationIntervalSeconds"];
requireMfa?: C["requireMfa"];
}>; }>;
accountResponseSchema: z.ZodTypeAny; accountResponseSchema: z.ZodTypeAny;
}) => { }) => {
@@ -66,7 +68,8 @@ export const registerPamAccountEndpoints = <C extends TPamAccount>({
name: req.body.name, name: req.body.name,
description: req.body.description, description: req.body.description,
rotationEnabled: req.body.rotationEnabled ?? false, rotationEnabled: req.body.rotationEnabled ?? false,
rotationIntervalSeconds: req.body.rotationIntervalSeconds rotationIntervalSeconds: req.body.rotationIntervalSeconds,
requireMfa: req.body.requireMfa
} }
} }
}); });
@@ -116,7 +119,8 @@ export const registerPamAccountEndpoints = <C extends TPamAccount>({
name: req.body.name, name: req.body.name,
description: req.body.description, description: req.body.description,
rotationEnabled: req.body.rotationEnabled, rotationEnabled: req.body.rotationEnabled,
rotationIntervalSeconds: req.body.rotationIntervalSeconds rotationIntervalSeconds: req.body.rotationIntervalSeconds,
requireMfa: req.body.requireMfa
} }
} }
}); });

View File

@@ -115,6 +115,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
body: z.object({ body: z.object({
accountPath: z.string().trim(), accountPath: z.string().trim(),
projectId: z.string().uuid(), projectId: z.string().uuid(),
mfaSessionId: z.string().optional(),
duration: z duration: z
.string() .string()
.min(1) .min(1)
@@ -163,7 +164,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
actorUserAgent: req.auditLogInfo.userAgent ?? "", actorUserAgent: req.auditLogInfo.userAgent ?? "",
accountPath: req.body.accountPath, accountPath: req.body.accountPath,
projectId: req.body.projectId, projectId: req.body.projectId,
duration: req.body.duration duration: req.body.duration,
mfaSessionId: req.body.mfaSessionId
}, },
req.permission req.permission
); );

View File

@@ -4199,6 +4199,7 @@ interface PamAccountCreateEvent {
description?: string | null; description?: string | null;
rotationEnabled: boolean; rotationEnabled: boolean;
rotationIntervalSeconds?: number | null; rotationIntervalSeconds?: number | null;
requireMfa?: boolean | null;
}; };
} }
@@ -4212,6 +4213,7 @@ interface PamAccountUpdateEvent {
description?: string | null; description?: string | null;
rotationEnabled?: boolean; rotationEnabled?: boolean;
rotationIntervalSeconds?: number | null; rotationIntervalSeconds?: number | null;
requireMfa?: boolean | null;
}; };
} }

View File

@@ -38,11 +38,16 @@ import { TApprovalPolicyDALFactory } from "@app/services/approval-policy/approva
import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums"; import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums";
import { APPROVAL_POLICY_FACTORY_MAP } from "@app/services/approval-policy/approval-policy-factory"; import { APPROVAL_POLICY_FACTORY_MAP } from "@app/services/approval-policy/approval-policy-factory";
import { TApprovalRequestGrantsDALFactory } from "@app/services/approval-policy/approval-request-dal"; import { TApprovalRequestGrantsDALFactory } from "@app/services/approval-policy/approval-request-dal";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType, MfaMethod } from "@app/services/auth/auth-type";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types"; import { KmsDataKey } from "@app/services/kms/kms-types";
import { TMfaSessionServiceFactory } from "@app/services/mfa-session/mfa-session-service";
import { MfaSessionStatus } from "@app/services/mfa-session/mfa-session-types";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TPamSessionExpirationServiceFactory } from "@app/services/pam-session-expiration/pam-session-expiration-queue"; import { TPamSessionExpirationServiceFactory } from "@app/services/pam-session-expiration/pam-session-expiration-queue";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TSmtpService } from "@app/services/smtp/smtp-service";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types"; import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types";
@@ -68,7 +73,9 @@ type TPamAccountServiceFactoryDep = {
pamSessionDAL: TPamSessionDALFactory; pamSessionDAL: TPamSessionDALFactory;
pamAccountDAL: TPamAccountDALFactory; pamAccountDAL: TPamAccountDALFactory;
pamFolderDAL: TPamFolderDALFactory; pamFolderDAL: TPamFolderDALFactory;
mfaSessionService: TMfaSessionServiceFactory;
projectDAL: TProjectDALFactory; projectDAL: TProjectDALFactory;
orgDAL: TOrgDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
@@ -78,10 +85,13 @@ type TPamAccountServiceFactoryDep = {
>; >;
userDAL: TUserDALFactory; userDAL: TUserDALFactory;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">; auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
smtpService: Pick<TSmtpService, "sendMail">;
approvalPolicyDAL: TApprovalPolicyDALFactory; approvalPolicyDAL: TApprovalPolicyDALFactory;
approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory; approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory;
pamSessionExpirationService: Pick<TPamSessionExpirationServiceFactory, "scheduleSessionExpiration">; pamSessionExpirationService: Pick<TPamSessionExpirationServiceFactory, "scheduleSessionExpiration">;
}; };
export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>; export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>;
const ROTATION_CONCURRENCY_LIMIT = 10; const ROTATION_CONCURRENCY_LIMIT = 10;
@@ -90,8 +100,10 @@ export const pamAccountServiceFactory = ({
pamResourceDAL, pamResourceDAL,
pamSessionDAL, pamSessionDAL,
pamAccountDAL, pamAccountDAL,
mfaSessionService,
pamFolderDAL, pamFolderDAL,
projectDAL, projectDAL,
orgDAL,
userDAL, userDAL,
permissionService, permissionService,
licenseService, licenseService,
@@ -110,7 +122,8 @@ export const pamAccountServiceFactory = ({
description, description,
folderId, folderId,
rotationEnabled, rotationEnabled,
rotationIntervalSeconds rotationIntervalSeconds,
requireMfa
}: TCreateAccountDTO, }: TCreateAccountDTO,
actor: OrgServiceActor actor: OrgServiceActor
) => { ) => {
@@ -198,7 +211,8 @@ export const pamAccountServiceFactory = ({
description, description,
folderId, folderId,
rotationEnabled, rotationEnabled,
rotationIntervalSeconds rotationIntervalSeconds,
requireMfa
}); });
return { return {
@@ -222,7 +236,15 @@ export const pamAccountServiceFactory = ({
}; };
const updateById = async ( const updateById = async (
{ accountId, credentials, description, name, rotationEnabled, rotationIntervalSeconds }: TUpdateAccountDTO, {
accountId,
credentials,
description,
name,
rotationEnabled,
rotationIntervalSeconds,
requireMfa
}: TUpdateAccountDTO,
actor: OrgServiceActor actor: OrgServiceActor
) => { ) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId); const orgLicensePlan = await licenseService.getPlan(actor.orgId);
@@ -268,6 +290,10 @@ export const pamAccountServiceFactory = ({
updateDoc.name = name; updateDoc.name = name;
} }
if (requireMfa !== undefined) {
updateDoc.requireMfa = requireMfa;
}
if (description !== undefined) { if (description !== undefined) {
updateDoc.description = description; updateDoc.description = description;
} }
@@ -552,7 +578,16 @@ export const pamAccountServiceFactory = ({
}; };
const access = async ( const access = async (
{ accountPath, projectId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO, {
accountPath,
projectId,
actorEmail,
actorIp,
actorName,
actorUserAgent,
duration,
mfaSessionId
}: TAccessAccountDTO,
actor: OrgServiceActor actor: OrgServiceActor
) => { ) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId); const orgLicensePlan = await licenseService.getPlan(actor.orgId);
@@ -640,6 +675,76 @@ export const pamAccountServiceFactory = ({
); );
} }
const project = await projectDAL.findById(account.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${account.projectId}' not found` });
const actorUser = await userDAL.findById(actor.id);
if (!actorUser) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` });
// If no mfaSessionId is provided, create a new MFA session
if (!mfaSessionId && account.requireMfa) {
// Get organization to check if MFA is enforced at org level
const org = await orgDAL.findOrgById(project.orgId);
if (!org) throw new NotFoundError({ message: `Organization with ID '${project.orgId}' not found` });
// Determine which MFA method to use
// Priority: org-enforced > user-selected > email as fallback
const orgMfaMethod = org.enforceMfa ? (org.selectedMfaMethod as MfaMethod | null) : undefined;
const userMfaMethod = actorUser.isMfaEnabled ? (actorUser.selectedMfaMethod as MfaMethod | null) : undefined;
const mfaMethod = (orgMfaMethod ?? userMfaMethod ?? MfaMethod.EMAIL) as MfaMethod;
// Create MFA session
const newMfaSessionId = await mfaSessionService.createMfaSession(actorUser.id, account.id, mfaMethod);
// If MFA method is email, send the code immediately
if (mfaMethod === MfaMethod.EMAIL && actorUser.email) {
await mfaSessionService.sendMfaCode(actorUser.id, actorUser.email);
}
// Throw an error with the mfaSessionId to signal that MFA is required
throw new BadRequestError({
message: "MFA verification required to access PAM account",
name: "SESSION_MFA_REQUIRED",
details: {
mfaSessionId: newMfaSessionId,
mfaMethod
}
});
}
if (mfaSessionId && account.requireMfa) {
const mfaSession = await mfaSessionService.getMfaSession(mfaSessionId);
if (!mfaSession) {
throw new BadRequestError({
message: "MFA session not found or expired"
});
}
// Verify the session belongs to the current user
if (mfaSession.userId !== actor.id) {
throw new BadRequestError({
message: "MFA session does not belong to current user"
});
}
// Verify the session is for the same account
if (mfaSession.resourceId !== account.id) {
throw new BadRequestError({
message: "MFA session is for a different account"
});
}
// Check if MFA session is active
if (mfaSession.status !== MfaSessionStatus.ACTIVE) {
throw new BadRequestError({
message: "MFA session is not active. Please complete MFA verification first."
});
}
// MFA verified successfully, delete the session and proceed with access
await mfaSessionService.deleteMfaSession(mfaSessionId);
}
const { connectionDetails, gatewayId, resourceType } = await decryptResource( const { connectionDetails, gatewayId, resourceType } = await decryptResource(
resource, resource,
account.projectId, account.projectId,

View File

@@ -6,7 +6,7 @@ import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums";
// DTOs // DTOs
export type TCreateAccountDTO = Pick< export type TCreateAccountDTO = Pick<
TPamAccount, TPamAccount,
"name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationIntervalSeconds" "name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationIntervalSeconds" | "requireMfa"
> & { > & {
rotationEnabled?: boolean; rotationEnabled?: boolean;
}; };
@@ -23,6 +23,7 @@ export type TAccessAccountDTO = {
actorName: string; actorName: string;
actorUserAgent: string; actorUserAgent: string;
duration: number; duration: number;
mfaSessionId?: string;
}; };
export type TListAccountsDTO = { export type TListAccountsDTO = {

View File

@@ -66,12 +66,14 @@ export const BaseCreatePamAccountSchema = z.object({
name: slugSchema({ field: "name" }), name: slugSchema({ field: "name" }),
description: z.string().max(512).nullable().optional(), description: z.string().max(512).nullable().optional(),
rotationEnabled: z.boolean(), rotationEnabled: z.boolean(),
rotationIntervalSeconds: z.number().min(3600).nullable().optional() rotationIntervalSeconds: z.number().min(3600).nullable().optional(),
requireMfa: z.boolean().optional().default(false)
}); });
export const BaseUpdatePamAccountSchema = z.object({ export const BaseUpdatePamAccountSchema = z.object({
name: slugSchema({ field: "name" }).optional(), name: slugSchema({ field: "name" }).optional(),
description: z.string().max(512).nullable().optional(), description: z.string().max(512).nullable().optional(),
rotationEnabled: z.boolean().optional(), rotationEnabled: z.boolean().optional(),
rotationIntervalSeconds: z.number().min(3600).nullable().optional() rotationIntervalSeconds: z.number().min(3600).nullable().optional(),
requireMfa: z.boolean().optional()
}); });

View File

@@ -81,6 +81,8 @@ export const KeyStorePrefixes = {
`group-member-project-permission:${projectId}:${groupId}:*` as const, `group-member-project-permission:${projectId}:${groupId}:*` as const,
PkiAcmeNonce: (nonce: string) => `pki-acme-nonce:${nonce}` as const, PkiAcmeNonce: (nonce: string) => `pki-acme-nonce:${nonce}` as const,
MfaSession: (mfaSessionId: string) => `mfa-session:${mfaSessionId}` as const,
WebAuthnChallenge: (userId: string) => `webauthn-challenge:${userId}` as const,
AiMcpServerOAuth: (sessionId: string) => `ai-mcp-server-oauth:${sessionId}` as const, AiMcpServerOAuth: (sessionId: string) => `ai-mcp-server-oauth:${sessionId}` as const,
@@ -94,7 +96,9 @@ export const KeyStoreTtls = {
SetSecretSyncLastRunTimestampInSeconds: 60, SetSecretSyncLastRunTimestampInSeconds: 60,
AccessTokenStatusUpdateInSeconds: 120, AccessTokenStatusUpdateInSeconds: 120,
ProjectPermissionCacheInSeconds: 300, // 5 minutes ProjectPermissionCacheInSeconds: 300, // 5 minutes
ProjectPermissionDalVersionTtl: "15m" // Project permission DAL version TTL ProjectPermissionDalVersionTtl: "15m", // Project permission DAL version TTL
MfaSessionInSeconds: 300, // 5 minutes
WebAuthnChallengeInSeconds: 300 // 5 minutes
}; };
type TDeleteItems = { type TDeleteItems = {

View File

@@ -391,6 +391,9 @@ const envSchema = z
}) })
), ),
/* OracleDB ----------------------------------------------------------------------------- */
TNS_ADMIN: zpStr(z.string().optional()),
/* INTERNAL ----------------------------------------------------------------------------- */ /* INTERNAL ----------------------------------------------------------------------------- */
INTERNAL_REGION: zpStr(z.enum(["us", "eu"]).optional()) INTERNAL_REGION: zpStr(z.enum(["us", "eu"]).optional())
}) })

View File

@@ -90,10 +90,23 @@ export class BadRequestError extends Error {
error: unknown; error: unknown;
constructor({ name, error, message }: { message?: string; name?: string; error?: unknown }) { details?: unknown;
constructor({
name,
error,
message,
details
}: {
message?: string;
name?: string;
error?: unknown;
details?: unknown;
}) {
super(message ?? "The request is invalid"); super(message ?? "The request is invalid");
this.name = name || "BadRequest"; this.name = name || "BadRequest";
this.error = error; this.error = error;
this.details = details;
} }
} }

View File

@@ -134,9 +134,13 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
} }
if (error instanceof BadRequestError) { if (error instanceof BadRequestError) {
void res void res.status(HttpStatusCodes.BadRequest).send({
.status(HttpStatusCodes.BadRequest) reqId: req.id,
.send({ reqId: req.id, statusCode: HttpStatusCodes.BadRequest, message: error.message, error: error.name }); statusCode: HttpStatusCodes.BadRequest,
message: error.message,
error: error.name,
details: error.details
});
} else if (error instanceof NotFoundError) { } else if (error instanceof NotFoundError) {
void res void res
.status(HttpStatusCodes.NotFound) .status(HttpStatusCodes.NotFound)

View File

@@ -293,6 +293,7 @@ import { membershipIdentityDALFactory } from "@app/services/membership-identity/
import { membershipIdentityServiceFactory } from "@app/services/membership-identity/membership-identity-service"; import { membershipIdentityServiceFactory } from "@app/services/membership-identity/membership-identity-service";
import { membershipUserDALFactory } from "@app/services/membership-user/membership-user-dal"; import { membershipUserDALFactory } from "@app/services/membership-user/membership-user-dal";
import { membershipUserServiceFactory } from "@app/services/membership-user/membership-user-service"; import { membershipUserServiceFactory } from "@app/services/membership-user/membership-user-service";
import { mfaSessionServiceFactory } from "@app/services/mfa-session/mfa-session-service";
import { microsoftTeamsIntegrationDALFactory } from "@app/services/microsoft-teams/microsoft-teams-integration-dal"; import { microsoftTeamsIntegrationDALFactory } from "@app/services/microsoft-teams/microsoft-teams-integration-dal";
import { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
import { projectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; import { projectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal";
@@ -391,6 +392,8 @@ import { userDALFactory } from "@app/services/user/user-dal";
import { userServiceFactory } from "@app/services/user/user-service"; import { userServiceFactory } from "@app/services/user/user-service";
import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { userEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { userEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service";
import { webAuthnCredentialDALFactory } from "@app/services/webauthn/webauthn-credential-dal";
import { webAuthnServiceFactory } from "@app/services/webauthn/webauthn-service";
import { webhookDALFactory } from "@app/services/webhook/webhook-dal"; import { webhookDALFactory } from "@app/services/webhook/webhook-dal";
import { webhookServiceFactory } from "@app/services/webhook/webhook-service"; import { webhookServiceFactory } from "@app/services/webhook/webhook-service";
import { workflowIntegrationDALFactory } from "@app/services/workflow-integration/workflow-integration-dal"; import { workflowIntegrationDALFactory } from "@app/services/workflow-integration/workflow-integration-dal";
@@ -570,6 +573,7 @@ export const registerRoutes = async (
const projectSlackConfigDAL = projectSlackConfigDALFactory(db); const projectSlackConfigDAL = projectSlackConfigDALFactory(db);
const workflowIntegrationDAL = workflowIntegrationDALFactory(db); const workflowIntegrationDAL = workflowIntegrationDALFactory(db);
const totpConfigDAL = totpConfigDALFactory(db); const totpConfigDAL = totpConfigDALFactory(db);
const webAuthnCredentialDAL = webAuthnCredentialDALFactory(db);
const externalGroupOrgRoleMappingDAL = externalGroupOrgRoleMappingDALFactory(db); const externalGroupOrgRoleMappingDAL = externalGroupOrgRoleMappingDALFactory(db);
@@ -914,6 +918,13 @@ export const registerRoutes = async (
kmsService kmsService
}); });
const webAuthnService = webAuthnServiceFactory({
webAuthnCredentialDAL,
userDAL,
tokenService,
keyStore
});
const loginService = authLoginServiceFactory({ const loginService = authLoginServiceFactory({
userDAL, userDAL,
smtpService, smtpService,
@@ -2451,6 +2462,13 @@ export const registerRoutes = async (
gatewayV2Service gatewayV2Service
}); });
const mfaSessionService = mfaSessionServiceFactory({
keyStore,
tokenService,
smtpService,
totpService
});
const approvalPolicyDAL = approvalPolicyDALFactory(db); const approvalPolicyDAL = approvalPolicyDALFactory(db);
const pamSessionExpirationService = pamSessionExpirationServiceFactory({ const pamSessionExpirationService = pamSessionExpirationServiceFactory({
queueService, queueService,
@@ -2467,8 +2485,12 @@ export const registerRoutes = async (
pamSessionDAL, pamSessionDAL,
permissionService, permissionService,
projectDAL, projectDAL,
orgDAL,
userDAL, userDAL,
auditLogService, auditLogService,
mfaSessionService,
tokenService,
smtpService,
approvalRequestGrantsDAL, approvalRequestGrantsDAL,
approvalPolicyDAL, approvalPolicyDAL,
pamSessionExpirationService pamSessionExpirationService
@@ -2702,6 +2724,7 @@ export const registerRoutes = async (
externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService, externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService,
projectTemplate: projectTemplateService, projectTemplate: projectTemplateService,
totp: totpService, totp: totpService,
webAuthn: webAuthnService,
appConnection: appConnectionService, appConnection: appConnectionService,
secretSync: secretSyncService, secretSync: secretSyncService,
kmip: kmipService, kmip: kmipService,
@@ -2723,6 +2746,7 @@ export const registerRoutes = async (
pamResource: pamResourceService, pamResource: pamResourceService,
pamAccount: pamAccountService, pamAccount: pamAccountService,
pamSession: pamSessionService, pamSession: pamSessionService,
mfaSession: mfaSessionService,
upgradePath: upgradePathService, upgradePath: upgradePathService,
membershipUser: membershipUserService, membershipUser: membershipUserService,

View File

@@ -37,7 +37,8 @@ export const DefaultResponseErrorsSchema = {
reqId: z.string(), reqId: z.string(),
statusCode: z.literal(400), statusCode: z.literal(400),
message: z.string(), message: z.string(),
error: z.string() error: z.string(),
details: z.any().optional()
}), }),
404: z.object({ 404: z.object({
reqId: z.string(), reqId: z.string(),

View File

@@ -2,7 +2,6 @@ import { z } from "zod";
import { import {
AccessScope, AccessScope,
OrgMembershipRole,
ProjectMembershipRole, ProjectMembershipRole,
ProjectMembershipsSchema, ProjectMembershipsSchema,
ProjectUserMembershipRolesSchema, ProjectUserMembershipRolesSchema,
@@ -275,7 +274,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
orgId: req.permission.orgId orgId: req.permission.orgId
}, },
data: { data: {
roles: [{ isTemporary: false, role: OrgMembershipRole.NoAccess }], roles: [],
usernames: usernamesAndEmails usernames: usernamesAndEmails
} }
}); });

View File

@@ -1,3 +1,4 @@
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
import { z } from "zod"; import { z } from "zod";
import { UsersSchema } from "@app/db/schemas"; import { UsersSchema } from "@app/db/schemas";
@@ -284,4 +285,217 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
}); });
} }
}); });
// WebAuthn/Passkey Routes
server.route({
method: "GET",
url: "/me/webauthn",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.object({
credentials: z.array(
z.object({
id: z.string(),
credentialId: z.string(),
name: z.string().nullable().optional(),
transports: z.array(z.string()).nullable().optional(),
createdAt: z.date(),
lastUsedAt: z.date().nullable().optional()
})
)
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const credentials = await server.services.webAuthn.getUserWebAuthnCredentials({
userId: req.permission.id
});
return { credentials };
}
});
server.route({
method: "POST",
url: "/me/webauthn/register",
config: {
rateLimit: writeLimit
},
schema: {
response: {
200: z.any() // Returns PublicKeyCredentialCreationOptionsJSON from @simplewebauthn/server
}
},
onRequest: verifyAuth([AuthMode.JWT], {
requireOrg: false
}),
handler: async (req) => {
return server.services.webAuthn.generateRegistrationOptions({
userId: req.permission.id
});
}
});
server.route({
method: "POST",
url: "/me/webauthn/register/verify",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
registrationResponse: z
.object({
id: z.string(),
rawId: z.string(),
response: z
.object({
clientDataJSON: z.string(),
attestationObject: z.string()
})
.passthrough(),
clientExtensionResults: z.record(z.unknown()).default({}),
type: z.literal("public-key")
})
.passthrough(),
name: z.string().optional()
}),
response: {
200: z.object({
credentialId: z.string(),
name: z.string().nullable().optional()
})
}
},
onRequest: verifyAuth([AuthMode.JWT], {
requireOrg: false
}),
handler: async (req) => {
return server.services.webAuthn.verifyRegistrationResponse({
userId: req.permission.id,
registrationResponse: req.body.registrationResponse as RegistrationResponseJSON,
name: req.body.name
});
}
});
server.route({
method: "POST",
url: "/me/webauthn/authenticate",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.any() // Returns PublicKeyCredentialRequestOptionsJSON from @simplewebauthn/server
}
},
onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }),
handler: async (req) => {
return server.services.webAuthn.generateAuthenticationOptions({
userId: req.permission.id
});
}
});
server.route({
method: "POST",
url: "/me/webauthn/authenticate/verify",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
authenticationResponse: z
.object({
id: z.string(),
rawId: z.string(),
response: z
.object({
clientDataJSON: z.string(),
authenticatorData: z.string(),
signature: z.string()
})
.passthrough(),
clientExtensionResults: z.record(z.unknown()).optional(),
type: z.literal("public-key")
})
.passthrough()
}),
response: {
200: z.object({
verified: z.boolean(),
credentialId: z.string(),
sessionToken: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }),
handler: async (req) => {
return server.services.webAuthn.verifyAuthenticationResponse({
userId: req.permission.id,
authenticationResponse: req.body.authenticationResponse as AuthenticationResponseJSON
});
}
});
server.route({
method: "PATCH",
url: "/me/webauthn/:id",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
id: z.string()
}),
body: z.object({
name: z.string().optional()
}),
response: {
200: z.object({
id: z.string(),
credentialId: z.string(),
name: z.string().nullable().optional()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
return server.services.webAuthn.updateWebAuthnCredential({
userId: req.permission.id,
id: req.params.id,
name: req.body.name
});
}
});
server.route({
method: "DELETE",
url: "/me/webauthn/:id",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
id: z.string()
}),
response: {
200: z.object({
success: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
await server.services.webAuthn.deleteWebAuthnCredential({
userId: req.permission.id,
id: req.params.id
});
return { success: true };
}
});
}; };

View File

@@ -6,6 +6,7 @@ import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-
import { registerDeprecatedProjectRouter } from "./deprecated-project-router"; import { registerDeprecatedProjectRouter } from "./deprecated-project-router";
import { registerIdentityOrgRouter } from "./identity-org-router"; import { registerIdentityOrgRouter } from "./identity-org-router";
import { registerMfaRouter } from "./mfa-router"; import { registerMfaRouter } from "./mfa-router";
import { registerMfaSessionRouter } from "./mfa-session-router";
import { registerOrgRouter } from "./organization-router"; import { registerOrgRouter } from "./organization-router";
import { registerPasswordRouter } from "./password-router"; import { registerPasswordRouter } from "./password-router";
import { registerPkiAlertRouter } from "./pki-alert-router"; import { registerPkiAlertRouter } from "./pki-alert-router";
@@ -17,6 +18,7 @@ import { registerUserRouter } from "./user-router";
export const registerV2Routes = async (server: FastifyZodProvider) => { export const registerV2Routes = async (server: FastifyZodProvider) => {
await server.register(registerMfaRouter, { prefix: "/auth" }); await server.register(registerMfaRouter, { prefix: "/auth" });
await server.register(registerMfaSessionRouter, { prefix: "/mfa-sessions" });
await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerUserRouter, { prefix: "/users" });
await server.register(registerServiceTokenRouter, { prefix: "/service-token" }); await server.register(registerServiceTokenRouter, { prefix: "/service-token" });
await server.register(registerPasswordRouter, { prefix: "/password" }); await server.register(registerPasswordRouter, { prefix: "/password" });

View File

@@ -186,4 +186,37 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
return handleMfaVerification(req, res, server, req.body.recoveryCode, MfaMethod.TOTP, true); return handleMfaVerification(req, res, server, req.body.recoveryCode, MfaMethod.TOTP, true);
} }
}); });
// WebAuthn MFA routes
server.route({
method: "GET",
url: "/mfa/check/webauthn",
config: {
rateLimit: mfaRateLimit
},
schema: {
response: {
200: z.object({
hasPasskeys: z.boolean()
})
}
},
handler: async (req) => {
try {
const credentials = await server.services.webAuthn.getUserWebAuthnCredentials({
userId: req.mfa.userId
});
return {
hasPasskeys: credentials.length > 0
};
} catch (error) {
if (error instanceof NotFoundError) {
return { hasPasskeys: false };
}
throw error;
}
}
});
}; };

View File

@@ -0,0 +1,72 @@
import { z } from "zod";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode, MfaMethod } from "@app/services/auth/auth-type";
import { MfaSessionStatus } from "@app/services/mfa-session/mfa-session-types";
export const registerMfaSessionRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/:mfaSessionId/verify",
config: {
rateLimit: writeLimit
},
schema: {
description: "Verify MFA session",
params: z.object({
mfaSessionId: z.string().trim()
}),
body: z.object({
mfaToken: z.string().trim(),
mfaMethod: z.nativeEnum(MfaMethod)
}),
response: {
200: z.object({
success: z.boolean(),
message: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const result = await server.services.mfaSession.verifyMfaSession({
mfaSessionId: req.params.mfaSessionId,
userId: req.permission.id,
mfaToken: req.body.mfaToken,
mfaMethod: req.body.mfaMethod
});
return result;
}
});
server.route({
method: "GET",
url: "/:mfaSessionId/status",
config: {
rateLimit: readLimit
},
schema: {
description: "Get MFA session status",
params: z.object({
mfaSessionId: z.string().trim()
}),
response: {
200: z.object({
status: z.nativeEnum(MfaSessionStatus),
mfaMethod: z.nativeEnum(MfaMethod)
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const result = await server.services.mfaSession.getMfaSessionStatus({
mfaSessionId: req.params.mfaSessionId,
userId: req.permission.id
});
return result;
}
});
};

View File

@@ -1,4 +1,6 @@
import fs from "fs";
import knex, { Knex } from "knex"; import knex, { Knex } from "knex";
import oracledb from "oracledb";
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
@@ -7,6 +9,7 @@ import {
TSqlCredentialsRotationGeneratedCredentials, TSqlCredentialsRotationGeneratedCredentials,
TSqlCredentialsRotationWithConnection TSqlCredentialsRotationWithConnection
} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types"; } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
@@ -79,12 +82,46 @@ const getConnectionConfig = ({
} }
}; };
// if TNS_ADMIN is set and the directory exists, we assume it's a wallet connection for OracleDB
const isOracleWalletConnection = (app: AppConnection): boolean => {
const { TNS_ADMIN } = getConfig();
return app === AppConnection.OracleDB && !!TNS_ADMIN && fs.existsSync(TNS_ADMIN);
};
const getOracleWalletKnexClient = (
credentials: Pick<TSqlConnection["credentials"], "username" | "password" | "database">
): Knex => {
if (oracledb.thin) {
try {
oracledb.initOracleClient();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
throw new BadRequestError({
message: `Failed to initialize Oracle client: ${errorMessage}. See documentation for instructions: https://infisical.com/docs/integrations/app-connections/oracledb#mutual-tls-wallet`
});
}
}
return knex({
client: SQL_CONNECTION_CLIENT_MAP[AppConnection.OracleDB],
connection: {
user: credentials.username,
password: credentials.password,
connectString: credentials.database
}
});
};
export const getSqlConnectionClient = async (appConnection: Pick<TSqlConnection, "credentials" | "app">) => { export const getSqlConnectionClient = async (appConnection: Pick<TSqlConnection, "credentials" | "app">) => {
const { const {
app, app,
credentials: { host: baseHost, database, port, password, username } credentials: { host: baseHost, database, port, password, username }
} = appConnection; } = appConnection;
if (isOracleWalletConnection(app)) {
return getOracleWalletKnexClient({ username, password, database });
}
const [host] = await verifyHostInputValidity(baseHost); const [host] = await verifyHostInputValidity(baseHost);
const client = knex({ const client = knex({
@@ -119,21 +156,30 @@ export const executeWithPotentialGateway = async <T>(
targetPort: credentials.port targetPort: credentials.port
}); });
const createClient = (proxyPort: number): Knex => {
const { database, username, password } = credentials;
if (isOracleWalletConnection(app)) {
return getOracleWalletKnexClient({ username, password, database });
}
return knex({
client: SQL_CONNECTION_CLIENT_MAP[app],
connection: {
database: credentials.database,
port: proxyPort,
host: "localhost",
user: credentials.username,
password: credentials.password,
connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT,
...getConnectionConfig({ app, credentials })
}
});
};
if (platformConnectionDetails) { if (platformConnectionDetails) {
return withGatewayV2Proxy( return withGatewayV2Proxy(
async (proxyPort) => { async (proxyPort) => {
const client = knex({ const client = createClient(proxyPort);
client: SQL_CONNECTION_CLIENT_MAP[app],
connection: {
database: credentials.database,
port: proxyPort,
host: "localhost",
user: credentials.username,
password: credentials.password,
connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT,
...getConnectionConfig({ app, credentials })
}
});
try { try {
return await operation(client); return await operation(client);
} finally { } finally {
@@ -154,18 +200,7 @@ export const executeWithPotentialGateway = async <T>(
return withGatewayProxy( return withGatewayProxy(
async (proxyPort) => { async (proxyPort) => {
const client = knex({ const client = createClient(proxyPort);
client: SQL_CONNECTION_CLIENT_MAP[app],
connection: {
database: credentials.database,
port: proxyPort,
host: "localhost",
user: credentials.username,
password: credentials.password,
connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT,
...getConnectionConfig({ app, credentials })
}
});
try { try {
return await operation(client); return await operation(client);
} finally { } finally {

View File

@@ -74,6 +74,13 @@ export const getTokenConfig = (tokenType: TokenType) => {
const expiresAt = new Date(new Date().getTime() + 259200000); const expiresAt = new Date(new Date().getTime() + 259200000);
return { token, expiresAt }; return { token, expiresAt };
} }
case TokenType.TOKEN_WEBAUTHN_SESSION: {
// generate random hex token for WebAuthn session
const token = crypto.randomBytes(32).toString("hex");
const triesLeft = 1;
const expiresAt = new Date(new Date().getTime() + 60000); // 60 seconds
return { token, triesLeft, expiresAt };
}
default: { default: {
const token = crypto.randomBytes(16).toString("hex"); const token = crypto.randomBytes(16).toString("hex");
const expiresAt = new Date(); const expiresAt = new Date();

View File

@@ -8,7 +8,8 @@ export enum TokenType {
TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation", TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation",
TOKEN_EMAIL_PASSWORD_RESET = "passwordReset", TOKEN_EMAIL_PASSWORD_RESET = "passwordReset",
TOKEN_EMAIL_PASSWORD_SETUP = "passwordSetup", TOKEN_EMAIL_PASSWORD_SETUP = "passwordSetup",
TOKEN_USER_UNLOCK = "userUnlock" TOKEN_USER_UNLOCK = "userUnlock",
TOKEN_WEBAUTHN_SESSION = "webauthnSession"
} }
export type TCreateTokenForUserDTO = { export type TCreateTokenForUserDTO = {

View File

@@ -882,6 +882,18 @@ export const authLoginServiceFactory = ({
totp: mfaToken totp: mfaToken
}); });
} }
} else if (mfaMethod === MfaMethod.WEBAUTHN) {
if (!mfaToken) {
throw new BadRequestError({
message: "WebAuthn session token is required"
});
}
// Validate the one-time WebAuthn session token (passed as mfaToken)
await tokenService.validateTokenForUser({
type: TokenType.TOKEN_WEBAUTHN_SESSION,
userId,
code: mfaToken
});
} }
} catch (err) { } catch (err) {
const updatedUser = await processFailedMfaAttempt(userId); const updatedUser = await processFailedMfaAttempt(userId);

View File

@@ -100,5 +100,6 @@ export type AuthModeProviderSignUpTokenPayload = {
export enum MfaMethod { export enum MfaMethod {
EMAIL = "email", EMAIL = "email",
TOTP = "totp" TOTP = "totp",
WEBAUTHN = "webauthn"
} }

View File

@@ -1,5 +1,6 @@
import { import {
AccessScope, AccessScope,
OrgMembershipRole,
OrgMembershipStatus, OrgMembershipStatus,
ProjectMembershipRole, ProjectMembershipRole,
TemporaryPermissionMode, TemporaryPermissionMode,
@@ -157,13 +158,22 @@ export const membershipUserServiceFactory = ({
const { scopeData, data } = dto; const { scopeData, data } = dto;
const factory = scopeFactory[scopeData.scope]; const factory = scopeFactory[scopeData.scope];
const hasNoPermanentRole = data.roles.every((el) => el.isTemporary); const orgDetails = await orgDAL.findById(dto.permission.orgId);
// If roles array is empty and scope is Organization, use org's default role
let rolesToUse = data.roles;
if (data.roles.length === 0 && scopeData.scope === AccessScope.Organization) {
const defaultRole = orgDetails.defaultMembershipRole || OrgMembershipRole.NoAccess;
rolesToUse = [{ isTemporary: false, role: defaultRole }];
}
const hasNoPermanentRole = rolesToUse.every((el) => el.isTemporary);
if (hasNoPermanentRole) { if (hasNoPermanentRole) {
throw new BadRequestError({ throw new BadRequestError({
message: "User must have at least one permanent role" message: "User must have at least one permanent role"
}); });
} }
const isInvalidTemporaryRole = data.roles.some((el) => { const isInvalidTemporaryRole = rolesToUse.some((el) => {
if (el.isTemporary) { if (el.isTemporary) {
if (!el.temporaryAccessStartTime || !el.temporaryRange) { if (!el.temporaryAccessStartTime || !el.temporaryRange) {
return true; return true;
@@ -188,7 +198,6 @@ export const membershipUserServiceFactory = ({
}); });
if (existingMemberships.length === users.length) return { memberships: [] }; if (existingMemberships.length === users.length) return { memberships: [] };
const orgDetails = await orgDAL.findById(dto.permission.orgId);
const isSubOrganization = Boolean(orgDetails.rootOrgId); const isSubOrganization = Boolean(orgDetails.rootOrgId);
const newMembershipUsers = users.filter((user) => !existingMemberships?.find((el) => el.actorUserId === user.id)); const newMembershipUsers = users.filter((user) => !existingMemberships?.find((el) => el.actorUserId === user.id));
@@ -212,7 +221,7 @@ export const membershipUserServiceFactory = ({
}; };
}); });
const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role)); const customInputRoles = rolesToUse.filter((el) => factory.isCustomRole(el.role));
const hasCustomRole = customInputRoles.length > 0; const hasCustomRole = customInputRoles.length > 0;
if (hasCustomRole) { if (hasCustomRole) {
const plan = await licenseService.getPlan(scopeData.orgId); const plan = await licenseService.getPlan(scopeData.orgId);
@@ -241,7 +250,7 @@ export const membershipUserServiceFactory = ({
const roleDocs: TMembershipRolesInsert[] = []; const roleDocs: TMembershipRolesInsert[] = [];
docs.forEach((membership) => { docs.forEach((membership) => {
data.roles.forEach((membershipRole) => { rolesToUse.forEach((membershipRole) => {
const isCustomRole = Boolean(customRolesGroupBySlug?.[membershipRole.role]?.[0]); const isCustomRole = Boolean(customRolesGroupBySlug?.[membershipRole.role]?.[0]);
if (membershipRole.isTemporary) { if (membershipRole.isTemporary) {
const relativeTimeInMs = membershipRole.temporaryRange ? ms(membershipRole.temporaryRange) : null; const relativeTimeInMs = membershipRole.temporaryRange ? ms(membershipRole.temporaryRange) : null;

View File

@@ -0,0 +1,175 @@
import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/keystore";
import { crypto } from "@app/lib/crypto";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { MfaMethod } from "@app/services/auth/auth-type";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
import { TokenType } from "@app/services/auth-token/auth-token-types";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TTotpServiceFactory } from "@app/services/totp/totp-service";
import { MfaSessionStatus, TGetMfaSessionStatusDTO, TMfaSession, TVerifyMfaSessionDTO } from "./mfa-session-types";
type TMfaSessionServiceFactoryDep = {
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
smtpService: Pick<TSmtpService, "sendMail">;
totpService: Pick<TTotpServiceFactory, "verifyUserTotp" | "verifyWithUserRecoveryCode">;
};
export type TMfaSessionServiceFactory = ReturnType<typeof mfaSessionServiceFactory>;
export const mfaSessionServiceFactory = ({
keyStore,
tokenService,
smtpService,
totpService
}: TMfaSessionServiceFactoryDep) => {
// Helper function to get MFA session from Redis
const getMfaSession = async (mfaSessionId: string): Promise<TMfaSession | null> => {
const mfaSessionKey = KeyStorePrefixes.MfaSession(mfaSessionId);
const mfaSessionData = await keyStore.getItem(mfaSessionKey);
if (!mfaSessionData) {
return null;
}
return JSON.parse(mfaSessionData) as TMfaSession;
};
// Helper function to update MFA session in Redis
const updateMfaSession = async (mfaSession: TMfaSession, ttlSeconds: number): Promise<void> => {
const mfaSessionKey = KeyStorePrefixes.MfaSession(mfaSession.sessionId);
await keyStore.setItemWithExpiry(mfaSessionKey, ttlSeconds, JSON.stringify(mfaSession));
};
// Helper function to send MFA code via email
const sendMfaCode = async (userId: string, email: string) => {
const code = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_MFA,
userId
});
await smtpService.sendMail({
template: SmtpTemplates.EmailMfa,
subjectLine: "Infisical MFA code",
recipients: [email],
substitutions: {
code
}
});
};
const verifyMfaSession = async ({ mfaSessionId, userId, mfaToken, mfaMethod }: TVerifyMfaSessionDTO) => {
const mfaSession = await getMfaSession(mfaSessionId);
if (!mfaSession) {
throw new BadRequestError({
message: "MFA session not found or expired"
});
}
if (mfaSession.mfaMethod !== mfaMethod) {
throw new BadRequestError({
message: "MFA method does not match the session"
});
}
// Verify the session belongs to the current user
if (mfaSession.userId !== userId) {
throw new ForbiddenRequestError({
message: "MFA session does not belong to current user"
});
}
try {
if (mfaMethod === MfaMethod.EMAIL) {
await tokenService.validateTokenForUser({
type: TokenType.TOKEN_EMAIL_MFA,
userId,
code: mfaToken
});
} else if (mfaMethod === MfaMethod.TOTP) {
if (mfaToken.length !== 6) {
throw new BadRequestError({
message: "Please use a valid TOTP code."
});
}
await totpService.verifyUserTotp({
userId,
totp: mfaToken
});
} else if (mfaMethod === MfaMethod.WEBAUTHN) {
await tokenService.validateTokenForUser({
type: TokenType.TOKEN_WEBAUTHN_SESSION,
userId,
code: mfaToken
});
}
} catch (error) {
throw new BadRequestError({
message: "Invalid MFA code"
});
}
mfaSession.status = MfaSessionStatus.ACTIVE;
await updateMfaSession(mfaSession, KeyStoreTtls.MfaSessionInSeconds);
return {
success: true,
message: "MFA verification successful"
};
};
const getMfaSessionStatus = async ({ mfaSessionId, userId }: TGetMfaSessionStatusDTO) => {
const mfaSession = await getMfaSession(mfaSessionId);
if (!mfaSession) {
throw new NotFoundError({
message: "MFA session not found or expired"
});
}
if (mfaSession.userId !== userId) {
throw new ForbiddenRequestError({
message: "MFA session does not belong to current user"
});
}
return {
status: mfaSession.status,
mfaMethod: mfaSession.mfaMethod
};
};
const createMfaSession = async (userId: string, resourceId: string, mfaMethod: MfaMethod): Promise<string> => {
const mfaSessionId = crypto.randomBytes(32).toString("hex");
const mfaSession: TMfaSession = {
sessionId: mfaSessionId,
userId,
resourceId,
status: MfaSessionStatus.PENDING,
mfaMethod
};
await keyStore.setItemWithExpiry(
KeyStorePrefixes.MfaSession(mfaSessionId),
KeyStoreTtls.MfaSessionInSeconds,
JSON.stringify(mfaSession)
);
return mfaSessionId;
};
const deleteMfaSession = async (mfaSessionId: string) => {
await keyStore.deleteItem(KeyStorePrefixes.MfaSession(mfaSessionId));
};
return {
createMfaSession,
verifyMfaSession,
getMfaSessionStatus,
sendMfaCode,
getMfaSession,
deleteMfaSession
};
};

View File

@@ -0,0 +1,32 @@
import { MfaMethod } from "@app/services/auth/auth-type";
export enum MfaSessionStatus {
PENDING = "PENDING",
ACTIVE = "ACTIVE"
}
export type TMfaSession = {
sessionId: string;
userId: string;
resourceId: string; // Generic - can be accountId, documentId, etc.
status: MfaSessionStatus;
mfaMethod: MfaMethod;
};
export type TCreateMfaSessionDTO = {
userId: string;
resourceId: string;
mfaMethod: MfaMethod;
};
export type TVerifyMfaSessionDTO = {
mfaSessionId: string;
userId: string;
mfaToken: string;
mfaMethod: MfaMethod;
};
export type TGetMfaSessionStatusDTO = {
mfaSessionId: string;
userId: string;
};

View File

@@ -0,0 +1,11 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TWebAuthnCredentialDALFactory = ReturnType<typeof webAuthnCredentialDALFactory>;
export const webAuthnCredentialDALFactory = (db: TDbClient) => {
const webAuthnCredentialDal = ormify(db, TableName.WebAuthnCredential);
return webAuthnCredentialDal;
};

View File

@@ -0,0 +1,385 @@
import {
AuthenticatorTransportFuture,
generateAuthenticationOptions,
generateRegistrationOptions,
VerifiedAuthenticationResponse,
VerifiedRegistrationResponse,
verifyAuthenticationResponse,
verifyRegistrationResponse
} from "@simplewebauthn/server";
import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType } from "../auth-token/auth-token-types";
import { TUserDALFactory } from "../user/user-dal";
import { TWebAuthnCredentialDALFactory } from "./webauthn-credential-dal";
import {
TDeleteWebAuthnCredentialDTO,
TGenerateAuthenticationOptionsDTO,
TGenerateRegistrationOptionsDTO,
TGetUserWebAuthnCredentialsDTO,
TUpdateWebAuthnCredentialDTO,
TVerifyAuthenticationResponseDTO,
TVerifyRegistrationResponseDTO
} from "./webauthn-types";
type TWebAuthnServiceFactoryDep = {
userDAL: TUserDALFactory;
webAuthnCredentialDAL: TWebAuthnCredentialDALFactory;
tokenService: TAuthTokenServiceFactory;
keyStore: TKeyStoreFactory;
};
export type TWebAuthnServiceFactory = ReturnType<typeof webAuthnServiceFactory>;
export const webAuthnServiceFactory = ({
userDAL,
webAuthnCredentialDAL,
tokenService,
keyStore
}: TWebAuthnServiceFactoryDep) => {
const storeChallenge = async (userId: string, challenge: string) => {
const challengeKey = KeyStorePrefixes.WebAuthnChallenge(userId);
await keyStore.setItemWithExpiry(challengeKey, KeyStoreTtls.WebAuthnChallengeInSeconds, challenge);
};
const getChallenge = async (userId: string): Promise<string | null> => {
const challengeKey = KeyStorePrefixes.WebAuthnChallenge(userId);
return keyStore.getItem(challengeKey);
};
const clearChallenge = async (userId: string) => {
const challengeKey = KeyStorePrefixes.WebAuthnChallenge(userId);
await keyStore.deleteItem(challengeKey);
};
const appCfg = getConfig();
// Relying Party (RP) information - extracted from SITE_URL
const RP_NAME = "Infisical";
const RP_ID = new URL(appCfg.SITE_URL || "http://localhost:8080").hostname;
const ORIGIN = appCfg.SITE_URL || "http://localhost:8080";
/**
* Generate registration options for a new passkey
* This is the first step in passkey registration
*/
const generateRegistrationOptionsForUser = async ({ userId }: TGenerateRegistrationOptionsDTO) => {
const user = await userDAL.findById(userId);
if (!user) {
throw new NotFoundError({
message: "User not found"
});
}
// Get existing credentials to exclude them from registration
const existingCredentials = await webAuthnCredentialDAL.find({ userId });
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: Buffer.from(userId, "utf-8"),
userName: user.email || "",
userDisplayName: user.email || "",
attestationType: "none",
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports as AuthenticatorTransportFuture[]
})),
authenticatorSelection: {
requireResidentKey: true,
residentKey: "required",
userVerification: "required"
}
});
// Store challenge for verification
await storeChallenge(userId, options.challenge);
return options;
};
/**
* Verify registration response and store the credential
* This is the second step in passkey registration
*/
const verifyRegistrationResponseFromUser = async ({
userId,
registrationResponse,
name
}: TVerifyRegistrationResponseDTO) => {
const user = await userDAL.findById(userId);
if (!user) {
throw new NotFoundError({
message: "User not found"
});
}
// Retrieve the stored challenge
const expectedChallenge = await getChallenge(userId);
if (!expectedChallenge) {
throw new BadRequestError({
message: "Challenge not found or expired. Please try registering again."
});
}
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: true
});
} catch (error: unknown) {
await clearChallenge(userId);
throw new BadRequestError({
message: `Registration verification failed: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
if (!verification.verified || !verification.registrationInfo) {
await clearChallenge(userId);
throw new BadRequestError({
message: "Registration verification failed"
});
}
const { credential: registeredCredential } = verification.registrationInfo;
// Check if credential already exists
const credentialIdBase64 = registeredCredential.id;
const existingCredential = await webAuthnCredentialDAL.findOne({
credentialId: credentialIdBase64
});
if (existingCredential) {
await clearChallenge(userId);
throw new BadRequestError({
message: "This credential has already been registered"
});
}
// Store the credential
const credential = await webAuthnCredentialDAL.create({
userId,
credentialId: credentialIdBase64,
publicKey: Buffer.from(registeredCredential.publicKey).toString("base64url"),
counter: registeredCredential.counter,
transports: registrationResponse.response.transports || null,
name: name || "Passkey"
});
// Clear the challenge
await clearChallenge(userId);
return {
credentialId: credential.credentialId,
name: credential.name
};
};
/**
* Generate authentication options for passkey verification
* This is used during login/2FA
*/
const generateAuthenticationOptionsForUser = async ({ userId }: TGenerateAuthenticationOptionsDTO) => {
const credentials = await webAuthnCredentialDAL.find({ userId });
if (credentials.length === 0) {
throw new NotFoundError({
message: "No passkeys registered for this user"
});
}
const options = await generateAuthenticationOptions({
rpID: RP_ID,
allowCredentials: credentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports as AuthenticatorTransportFuture[]
})),
userVerification: "required"
});
// Store challenge for verification
await storeChallenge(userId, options.challenge);
return options;
};
/**
* Verify authentication response
* This is used during login/2FA to verify the user's passkey
*/
const verifyAuthenticationResponseFromUser = async ({
userId,
authenticationResponse
}: TVerifyAuthenticationResponseDTO) => {
const credentialIdBase64 = authenticationResponse.id;
if (!credentialIdBase64) {
throw new BadRequestError({
message: "Invalid authentication response"
});
}
// Find the credential
const credential = await webAuthnCredentialDAL.findOne({ credentialId: credentialIdBase64 });
if (!credential) {
throw new NotFoundError({
message: "Credential not found"
});
}
// Verify the credential belongs to the user
if (userId !== credential.userId) {
throw new ForbiddenRequestError({
message: "Credential does not belong to this user"
});
}
// Retrieve the stored challenge
const expectedChallenge = await getChallenge(userId);
if (!expectedChallenge) {
throw new BadRequestError({
message: "Challenge not found or expired. Please try authenticating again."
});
}
let verification: VerifiedAuthenticationResponse;
try {
verification = await verifyAuthenticationResponse({
response: authenticationResponse,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
credential: {
id: credential.credentialId,
publicKey: Buffer.from(credential.publicKey, "base64url"),
counter: credential.counter
},
requireUserVerification: true
});
} catch (error: unknown) {
await clearChallenge(userId);
throw new BadRequestError({
message: `Authentication verification failed: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
if (!verification.verified) {
await clearChallenge(userId);
throw new BadRequestError({
message: "Authentication verification failed"
});
}
// Update last used timestamp and counter
await webAuthnCredentialDAL.updateById(credential.id, {
lastUsedAt: new Date(),
counter: verification.authenticationInfo.newCounter
});
// Clear the challenge
await clearChallenge(userId);
// Generate one-time WebAuthn session token with 60-second expiration
const sessionToken = await tokenService.createTokenForUser({
type: TokenType.TOKEN_WEBAUTHN_SESSION,
userId
});
return {
verified: true,
credentialId: credential.credentialId,
sessionToken
};
};
/**
* Get all WebAuthn credentials for a user
*/
const getUserWebAuthnCredentials = async ({ userId }: TGetUserWebAuthnCredentialsDTO) => {
const credentials = await webAuthnCredentialDAL.find({ userId });
// Don't return sensitive data like public keys
return credentials.map((cred) => ({
id: cred.id,
credentialId: cred.credentialId,
name: cred.name,
transports: cred.transports,
createdAt: cred.createdAt,
lastUsedAt: cred.lastUsedAt
}));
};
/**
* Delete a WebAuthn credential
*/
const deleteWebAuthnCredential = async ({ userId, id }: TDeleteWebAuthnCredentialDTO) => {
const credential = await webAuthnCredentialDAL.findById(id);
if (!credential) {
throw new NotFoundError({
message: "Credential not found"
});
}
if (userId !== credential.userId) {
throw new ForbiddenRequestError({
message: "Credential does not belong to this user"
});
}
await webAuthnCredentialDAL.deleteById(credential.id);
return {
success: true
};
};
/**
* Update a WebAuthn credential (e.g., rename it)
*/
const updateWebAuthnCredential = async ({ userId, id, name }: TUpdateWebAuthnCredentialDTO) => {
const credential = await webAuthnCredentialDAL.findById(id);
if (!credential) {
throw new NotFoundError({
message: "Credential not found"
});
}
if (userId !== credential.userId) {
throw new ForbiddenRequestError({
message: "Credential does not belong to this user"
});
}
const updatedCredential = await webAuthnCredentialDAL.updateById(credential.id, {
name: name || credential.name
});
return {
id: updatedCredential.id,
credentialId: updatedCredential.credentialId,
name: updatedCredential.name
};
};
return {
generateRegistrationOptions: generateRegistrationOptionsForUser,
verifyRegistrationResponse: verifyRegistrationResponseFromUser,
generateAuthenticationOptions: generateAuthenticationOptionsForUser,
verifyAuthenticationResponse: verifyAuthenticationResponseFromUser,
getUserWebAuthnCredentials,
deleteWebAuthnCredential,
updateWebAuthnCredential
};
};

View File

@@ -0,0 +1,35 @@
import { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
export type TGenerateRegistrationOptionsDTO = {
userId: string;
};
export type TVerifyRegistrationResponseDTO = {
userId: string;
registrationResponse: RegistrationResponseJSON;
name?: string; // User-friendly name for the credential
};
export type TGenerateAuthenticationOptionsDTO = {
userId: string;
};
export type TVerifyAuthenticationResponseDTO = {
userId: string;
authenticationResponse: AuthenticationResponseJSON;
};
export type TGetUserWebAuthnCredentialsDTO = {
userId: string;
};
export type TDeleteWebAuthnCredentialDTO = {
userId: string;
id: string;
};
export type TUpdateWebAuthnCredentialDTO = {
userId: string;
id: string;
name?: string;
};

View File

@@ -44,17 +44,113 @@ Infisical supports connecting to OracleDB using a database user.
</Tabs> </Tabs>
</Step> </Step>
<Step title="Get Connection Details"> <Step title="Get Connection Details">
You'll need the following information to create your Oracle Database connection: <Tabs>
- `host` - The hostname or IP address of your Oracle Database server <Tab title="One-way TLS">
- `port` - The port number your Oracle Database server is listening on (default: 1521) You'll need the following information to create your Oracle Database connection:
- `database` - The Oracle Service Name or SID (System Identifier) for the database you are connecting to. For example: `ORCL`, `FREEPDB1`, `XEPDB1` - `host` - The hostname or IP address of your Oracle Database server
- `username` - The user name of the login created in the steps above - `port` - The port number your Oracle Database server is listening on (default: 1521)
- `password` - The user password of the login created in the steps above - `database` - The Oracle Service Name or SID (System Identifier) for the database you are connecting to. For example: `ORCL`, `FREEPDB1`, `XEPDB1`
- `sslCertificate` (optional) - The SSL certificate required for connection (if configured) - `username` - The user name of the login created in the steps above
- `password` - The user password of the login created in the steps above
- `sslCertificate` (optional) - The SSL certificate required for connection (if configured)
<Note> <Note>
If you are self-hosting Infisical and intend to connect to an internal/private IP address, be sure to set the `ALLOW_INTERNAL_IP_CONNECTIONS` environment variable to `true`. If you are self-hosting Infisical and intend to connect to an internal/private IP address, be sure to set the `ALLOW_INTERNAL_IP_CONNECTIONS` environment variable to `true`.
</Note> </Note>
</Tab>
<Tab title="Mutual TLS (Wallet)">
<Info>
This configuration can only be done on self-hosted or dedicated instances of Infisical.
</Info>
Infisical includes Oracle Instant Client by default, enabling mTLS wallet-based connections without modifying the Docker image. You only need to mount your Oracle Wallet and configure the environment.
<Warning>
When `TNS_ADMIN` is set and points to a valid wallet directory, **all Oracle Database connections** in your Infisical instance will use the wallet for authentication.
**Gateway Limitation**: Wallet-based connections do not support [Infisical Gateway](/documentation/platform/gateways/overview). The connection details (host, port, protocol) are read directly from the `tnsnames.ora` file in the wallet, bypassing the gateway routing.
</Warning>
### Prerequisites
Your Oracle Wallet folder should contain the following files:
- `cwallet.sso` - Auto-login wallet (SSO wallet)
- `tnsnames.ora` - Connection aliases for your Oracle Database
- `sqlnet.ora` - Network configuration
### Configuration Steps
<Steps>
<Step title="Prepare your wallet">
Ensure your `sqlnet.ora` file points to the correct wallet directory. Update the `DIRECTORY` path to match where you'll mount the wallet in the container:
```ini
WALLET_LOCATION =
(SOURCE =
(METHOD = FILE)
(METHOD_DATA =
(DIRECTORY = /app/wallet)
)
)
SQLNET.AUTHENTICATION_SERVICES = (TCPS)
SSL_CLIENT_AUTHENTICATION = TRUE
```
</Step>
<Step title="Mount the wallet and set environment variables">
Mount your wallet directory and set the `TNS_ADMIN` environment variable to point to it.
**Environment Variable (`.env` file):**
```ini
TNS_ADMIN=/app/wallet
```
**Volume Mount Examples:**
<Tabs>
<Tab title="Docker">
```bash
docker run -d \
-v /path/to/your/wallet:/app/wallet:ro \
--env-file .env \
# ... other Infisical configuration ...
infisical/infisical:latest
```
</Tab>
<Tab title="Docker Compose">
```yaml
services:
infisical:
image: infisical/infisical:latest
env_file:
- .env
volumes:
- /path/to/your/wallet:/app/wallet:ro
# ... other Infisical configuration ...
```
</Tab>
</Tabs>
</Step>
<Step title="Create the connection">
You'll need the following information to create the connection in Infisical:
- `host` - The hostname or IP address of your Oracle Database server (required field, but not used for wallet connections).
- `port` - The port number your Oracle Database server is listening on (required field, but not used for wallet connections).
- `database` - The TNS alias for your Oracle Database from your `tnsnames.ora` file.
- `username` - The user name of the login created in the steps above.
- `password` - The user password of the login created in the steps above.
<Note>
When a wallet is detected (via the `TNS_ADMIN` environment variable), the connection uses the TNS alias from the `database` field to look up full connection details (host, port, protocol) from your `tnsnames.ora` file.
The host and port fields in the connection form are required but ignored for wallet connections. Any SSL settings in the connection form are also ignored - the wallet's certificates are used instead.
</Note>
</Step>
</Steps>
<Note>
If you are self-hosting Infisical and intend to connect to an internal/private IP address, be sure to set the `ALLOW_INTERNAL_IP_CONNECTIONS` environment variable to `true`.
</Note>
</Tab>
</Tabs>
</Step> </Step>
</Steps> </Steps>

View File

@@ -970,6 +970,13 @@ Please refer to the [templating functions documentation](/integrations/platforms
</Accordion> </Accordion>
<Accordion title="managedKubeSecretReferences[].template.metadata">
Define custom labels and annotations for the managed Kubernetes secret. This allows you to specify metadata that should be applied to the managed secret separately from the InfisicalSecret itself.
For detailed information on how metadata propagation works and examples, see the [Propagating Labels & Annotations](#propagating-labels-&-annotations) section.
</Accordion>
### Operator Managed ConfigMaps ### Operator Managed ConfigMaps
The managed config map properties specify where to store the secrets retrieved from your Infisical project. Config maps can be used to store **non-sensitive** data, such as application configuration variables. The managed config map properties specify where to store the secrets retrieved from your Infisical project. Config maps can be used to store **non-sensitive** data, such as application configuration variables.
@@ -1078,6 +1085,13 @@ Please refer to the [templating functions documentation](/integrations/platforms
</Accordion> </Accordion>
<Accordion title="managedKubeConfigMapReferences[].template.metadata">
Define custom labels and annotations for the managed Kubernetes ConfigMap. This allows you to specify metadata that should be applied to the managed ConfigMap separately from the InfisicalSecret itself.
This field works the same way as `template.metadata` for managed secrets. For detailed information on how metadata propagation works and examples, see the [Propagating Labels & Annotations](#propagating-labels-&-annotations) section.
</Accordion>
## Applying CRD ## Applying CRD
Once you have configured the InfisicalSecret CRD with the required fields, you can apply it to your cluster. Once you have configured the InfisicalSecret CRD with the required fields, you can apply it to your cluster.
@@ -1564,10 +1578,13 @@ stringData:
## Propagating Labels & Annotations ## Propagating Labels & Annotations
The operator will transfer all labels & annotations present on the `InfisicalSecret` CRD to the managed Kubernetes secret to be created. The operator provides flexible options for managing labels and annotations on managed Kubernetes secrets.
Thus, if a specific label is required on the resulting secret, it can be applied as demonstrated in the following example:
<Accordion title="Default Behavior (Without template.metadata)">
By default, the operator will transfer all labels & annotations present on the `InfisicalSecret` to the managed Kubernetes secret to be created.
### Example
<Accordion title="Example propagation">
```yaml ```yaml
apiVersion: secrets.infisical.com/v1alpha1 apiVersion: secrets.infisical.com/v1alpha1
kind: InfisicalSecret kind: InfisicalSecret
@@ -1578,28 +1595,96 @@ Thus, if a specific label is required on the resulting secret, it can be applied
annotations: annotations:
example.com/annotation-to-be-passed-to-managed-secret: "sample-value" example.com/annotation-to-be-passed-to-managed-secret: "sample-value"
spec: spec:
..
authentication: authentication:
... # ... auth config ...
managedKubeSecretReferences: managedKubeSecretReferences:
... - secretName: managed-token
secretNamespace: default
``` ```
This would result in the following managed secret to be created: This would result in the following managed secret to be created:
```yaml ```yaml
apiVersion: v1 apiVersion: v1
data: ... data: ...
kind: Secret kind: Secret
metadata: metadata:
annotations: annotations:
example.com/annotation-to-be-passed-to-managed-secret: sample-value example.com/annotation-to-be-passed-to-managed-secret: sample-value
secrets.infisical.com/version: W/"3f1-ZyOSsrCLGSkAhhCkY2USPu2ivRw" secrets.infisical.com/version: W/"3f1-ZyOSsrCLGSkAhhCkY2USPu2ivRw"
labels: labels:
label-to-be-passed-to-managed-secret: sample-value label-to-be-passed-to-managed-secret: sample-value
name: managed-token name: managed-token
namespace: default namespace: default
type: Opaque type: Opaque
``` ```
</Accordion>
<Accordion title="Custom Metadata (With template.metadata)">
When you specify `template.metadata` in your template configuration, you have full control over which labels and annotations are applied to the managed secret:
- Labels and annotations from `template.metadata` are used exclusively on the managed secret
- InfisicalSecret labels and annotations are NOT propagated to the managed secret
- This allows you to keep InfisicalSecret-specific metadata separate from the managed secret metadata
<Tip>
To prevent any propagation while using `template.metadata`, pass empty objects for labels and/or annotations.
This will ensure no labels or annotations are propagated to the managed secret, even from the InfisicalSecret CRD's own labels/annotations:
```yaml
template:
metadata:
labels: {}
annotations: {}
```
</Tip>
### Example
```yaml
apiVersion: secrets.infisical.com/v1alpha1
kind: InfisicalSecret
metadata:
name: infisicalsecret-with-template-metadata
labels:
managed-by: infisical-operator
annotations:
example.com/cr-specific: "metadata"
spec:
authentication:
# ... auth config ...
managedKubeSecretReferences:
- secretName: managed-secret-with-custom-metadata
secretNamespace: default
template:
includeAllSecrets: true
metadata:
labels:
app: my-application
environment: production
tier: backend
annotations:
secret.example.com/description: "Production database credentials"
secret.example.com/owner: "platform-team"
```
This would result in the following managed secret to be created:
```yaml
apiVersion: v1
data: ...
kind: Secret
metadata:
annotations:
secret.example.com/description: "Production database credentials"
secret.example.com/owner: "platform-team"
labels:
app: my-application
environment: production
tier: backend
name: managed-secret-with-custom-metadata
namespace: default
type: Opaque
```
</Accordion> </Accordion>

View File

@@ -48,6 +48,7 @@
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.3", "@radix-ui/react-toast": "^1.2.3",
"@radix-ui/react-tooltip": "^1.1.5", "@radix-ui/react-tooltip": "^1.1.5",
"@simplewebauthn/browser": "^13.2.2",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"@tanstack/react-router": "^1.95.1", "@tanstack/react-router": "^1.95.1",
@@ -4141,6 +4142,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@simplewebauthn/browser": {
"version": "13.2.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
"license": "MIT"
},
"node_modules/@sindresorhus/slugify": { "node_modules/@sindresorhus/slugify": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz",

View File

@@ -57,6 +57,7 @@
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.3", "@radix-ui/react-toast": "^1.2.3",
"@radix-ui/react-tooltip": "^1.1.5", "@radix-ui/react-tooltip": "^1.1.5",
"@simplewebauthn/browser": "^13.2.2",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"@tanstack/react-router": "^1.95.1", "@tanstack/react-router": "^1.95.1",

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import ReactCodeInput from "react-code-input"; import ReactCodeInput from "react-code-input";
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
import { Link, useNavigate } from "@tanstack/react-router"; import { Link, useNavigate } from "@tanstack/react-router";
import { t } from "i18next"; import { t } from "i18next";
@@ -7,11 +8,23 @@ import Error from "@app/components/basic/Error";
import TotpRegistration from "@app/components/mfa/TotpRegistration"; import TotpRegistration from "@app/components/mfa/TotpRegistration";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import SecurityClient from "@app/components/utilities/SecurityClient"; import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Tooltip } from "@app/components/v2"; import { Button, Input, Tooltip } from "@app/components/v2";
import { isInfisicalCloud } from "@app/helpers/platform"; import { isInfisicalCloud } from "@app/helpers/platform";
import { useLogoutUser, useSendMfaToken } from "@app/hooks/api"; import { useLogoutUser, useSendMfaToken } from "@app/hooks/api";
import { checkUserTotpMfa, verifyMfaToken, verifyRecoveryCode } from "@app/hooks/api/auth/queries"; import {
checkUserTotpMfa,
checkUserWebAuthnMfa,
verifyMfaToken,
verifyRecoveryCode
} from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types"; import { MfaMethod } from "@app/hooks/api/auth/types";
import { getMfaTempToken } from "@app/hooks/api/reactQuery";
import {
useGenerateAuthenticationOptions,
useGenerateRegistrationOptions,
useVerifyAuthentication,
useVerifyRegistration
} from "@app/hooks/api/webauthn";
// The style for the verification code input // The style for the verification code input
const codeInputProps = { const codeInputProps = {
@@ -68,9 +81,17 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
const [isLoadingResend, setIsLoadingResend] = useState(false); const [isLoadingResend, setIsLoadingResend] = useState(false);
const [triesLeft, setTriesLeft] = useState<number | undefined>(undefined); const [triesLeft, setTriesLeft] = useState<number | undefined>(undefined);
const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false); const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false);
const [shouldShowWebAuthnRegistration, setShouldShowWebAuthnRegistration] = useState(false);
const [credentialName, setCredentialName] = useState("");
const [isRegisteringPasskey, setIsRegisteringPasskey] = useState(false);
const logout = useLogoutUser(true); const logout = useLogoutUser(true);
const { mutateAsync: generateWebAuthnAuthenticationOptions } = useGenerateAuthenticationOptions();
const { mutateAsync: verifyWebAuthnAuthentication } = useVerifyAuthentication();
const sendMfaToken = useSendMfaToken(); const sendMfaToken = useSendMfaToken();
const generateRegistrationOptions = useGenerateRegistrationOptions();
const verifyRegistration = useVerifyRegistration();
useEffect(() => { useEffect(() => {
if (method === MfaMethod.TOTP) { if (method === MfaMethod.TOTP) {
@@ -80,8 +101,14 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
setShouldShowTotpRegistration(true); setShouldShowTotpRegistration(true);
} }
}); });
} else if (method === MfaMethod.WEBAUTHN) {
checkUserWebAuthnMfa().then((hasPasskeys) => {
if (!hasPasskeys) {
setShouldShowWebAuthnRegistration(true);
}
});
} }
}, []); }, [method]);
const getExpectedCodeLength = () => { const getExpectedCodeLength = () => {
if (method === MfaMethod.EMAIL) return 6; if (method === MfaMethod.EMAIL) return 6;
@@ -153,6 +180,153 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
} }
}; };
const handleWebAuthnVerification = async () => {
setIsLoading(true);
const mfaToken = getMfaTempToken();
try {
SecurityClient.setMfaToken("");
// Get authentication options from server
const options = await generateWebAuthnAuthenticationOptions();
// Prompt user to authenticate with their passkey
const authenticationResponse = await startAuthentication({ optionsJSON: options });
// Verify with server
const result = await verifyWebAuthnAuthentication({ authenticationResponse });
// Use the sessionToken to verify MFA
if (result.sessionToken) {
SecurityClient.setMfaToken(mfaToken);
const mfaResult = await verifyMfaToken({
email,
mfaCode: result.sessionToken,
mfaMethod: MfaMethod.WEBAUTHN
});
SecurityClient.setMfaToken("");
SecurityClient.setToken(mfaResult.token);
await successCallback();
if (closeMfa) {
closeMfa();
}
}
} catch (error: any) {
console.error("WebAuthn verification failed:", error);
let errorMessage = "Failed to verify passkey";
if (error.name === "NotAllowedError") {
errorMessage = "Passkey verification was cancelled or timed out";
} else if (error.message) {
errorMessage = error.message;
}
SecurityClient.setMfaToken(mfaToken);
createNotification({
text: errorMessage,
type: "error"
});
if (typeof triesLeft === "number") {
const newTriesLeft = triesLeft - 1;
setTriesLeft(newTriesLeft);
if (newTriesLeft <= 0) {
createNotification({
text: "User is temporary locked due to multiple failed login attempts. Try again later.",
type: "error"
});
SecurityClient.setMfaToken("");
SecurityClient.setToken("");
SecurityClient.setSignupToken("");
await logout.mutateAsync();
navigate({ to: "/login" });
return;
}
} else {
setTriesLeft(2);
}
} finally {
setIsLoading(false);
}
};
const handleRegisterPasskey = async () => {
try {
setIsRegisteringPasskey(true);
// Check if WebAuthn is supported
if (
!window.PublicKeyCredential ||
!window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable
) {
createNotification({
text: "WebAuthn is not supported on this browser",
type: "error"
});
return;
}
// Check if platform authenticator is available
const available =
await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
if (!available) {
createNotification({
text: "No passkey-compatible authenticator found on this device",
type: "error"
});
return;
}
// Temporarily clear MFA token so the regular access token is used for registration endpoints
const mfaToken = getMfaTempToken();
SecurityClient.setMfaToken("");
try {
// Generate registration options from server (using regular user endpoint)
const options = await generateRegistrationOptions.mutateAsync();
const registrationResponse = await startRegistration({ optionsJSON: options });
// Verify registration with server (using regular user endpoint)
await verifyRegistration.mutateAsync({
registrationResponse,
name: credentialName || "Passkey"
});
createNotification({
text: "Successfully registered passkey",
type: "success"
});
setShouldShowWebAuthnRegistration(false);
await successCallback();
} finally {
// Restore MFA token
SecurityClient.setMfaToken(mfaToken);
}
} catch (error: any) {
console.error("Failed to register passkey:", error);
let errorMessage = "Failed to register passkey";
if (error.name === "NotAllowedError") {
errorMessage = "Passkey registration was cancelled or timed out";
} else if (error.name === "InvalidStateError") {
errorMessage = "This passkey has already been registered";
} else if (error.message) {
errorMessage = error.message;
}
createNotification({
text: errorMessage,
type: "error"
});
} finally {
setIsRegisteringPasskey(false);
}
};
if (shouldShowTotpRegistration) { if (shouldShowTotpRegistration) {
return ( return (
<> <>
@@ -172,6 +346,35 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
); );
} }
if (shouldShowWebAuthnRegistration) {
return (
<>
<div className="mb-6 text-center text-lg font-bold text-white">
Your organization requires passkey authentication to be configured.
</div>
<div className="mx-auto w-max pt-4 pb-4 md:mb-16 md:px-8">
<div className="flex max-w-lg flex-col text-bunker-200">
<div className="mb-8">
1. Click the button below to register your passkey. You&apos;ll be prompted to use
your device&apos;s biometric authentication (Touch ID, Face ID, Windows Hello, etc.).
</div>
<div className="mb-4">2. Optionally, give your passkey a name to identify it later</div>
<div className="mb-4 flex flex-col gap-2">
<Input
onChange={(e) => setCredentialName(e.target.value)}
value={credentialName}
placeholder="Passkey name (optional)"
/>
<Button onClick={handleRegisterPasskey} isLoading={isRegisteringPasskey}>
Register Passkey
</Button>
</div>
</div>
</div>
</>
);
}
return ( return (
<div className="mx-auto w-max pt-6 pb-6 md:mb-16 md:px-8"> <div className="mx-auto w-max pt-6 pb-6 md:mb-16 md:px-8">
{!hideLogo && ( {!hideLogo && (
@@ -197,83 +400,113 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
</p> </p>
</div> </div>
)} )}
<form onSubmit={verifyMfa}> {method === MfaMethod.WEBAUTHN && (
<div className="mx-auto hidden md:block" style={{ minWidth: "600px" }}> <div className="mb-8 text-center">
{method === MfaMethod.EMAIL && ( <h2 className="mb-3 text-xl font-medium text-bunker-100">Passkey Authentication</h2>
<div className="flex justify-center"> <p className="mx-auto max-w-md text-sm leading-relaxed text-bunker-300">
<ReactCodeInput Use your registered passkey to complete two-factor authentication
name="" </p>
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mt-6 mb-2"
{...codeInputProps}
/>
</div>
)}
{method === MfaMethod.TOTP && (
<div className="mt-8 mb-6 flex justify-center">
<ReactCodeInput
key={showRecoveryCodeInput ? "recovery" : "totp"}
name=""
inputMode="tel"
type="text"
fields={showRecoveryCodeInput ? 8 : 6}
onChange={setMfaCode}
className="mb-2"
{...codeInputProps}
/>
</div>
)}
</div> </div>
<div className="mx-auto mt-4 block md:hidden" style={{ minWidth: "400px" }}> )}
{method === MfaMethod.EMAIL && ( {method === MfaMethod.WEBAUTHN ? (
<div className="flex justify-center"> <>
<ReactCodeInput {typeof triesLeft === "number" && (
name="" <Error text={`Failed authentication. You have ${triesLeft} attempt(s) remaining.`} />
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mt-2 mb-2"
{...codeInputPropsPhone}
/>
</div>
)} )}
{method === MfaMethod.TOTP && ( <div className="mx-auto mt-6 flex w-full max-w-sm flex-col items-center justify-center text-center">
<div className="mt-4 mb-6 flex justify-center"> <Button
<ReactCodeInput size="md"
key={showRecoveryCodeInput ? "recovery-mobile" : "totp-mobile"} onClick={handleWebAuthnVerification}
name="" isFullWidth
inputMode="tel" className="h-11 rounded-lg font-medium shadow-xs transition-all duration-200 hover:shadow-md"
type="text" colorSchema="primary"
fields={showRecoveryCodeInput ? 8 : 6} variant="outline_bg"
onChange={setMfaCode} isLoading={isLoading}
className="mb-2" isDisabled={typeof triesLeft === "number" && triesLeft <= 0}
{...codeInputPropsPhone} >
/> Authenticate with Passkey
</div> </Button>
</div>
</>
) : (
<form onSubmit={verifyMfa}>
<div className="mx-auto hidden md:block" style={{ minWidth: "600px" }}>
{method === MfaMethod.EMAIL && (
<div className="flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mt-6 mb-2"
{...codeInputProps}
/>
</div>
)}
{method === MfaMethod.TOTP && (
<div className="mt-8 mb-6 flex justify-center">
<ReactCodeInput
key={showRecoveryCodeInput ? "recovery" : "totp"}
name=""
inputMode="tel"
type="text"
fields={showRecoveryCodeInput ? 8 : 6}
onChange={setMfaCode}
className="mb-2"
{...codeInputProps}
/>
</div>
)}
</div>
<div className="mx-auto mt-4 block md:hidden" style={{ minWidth: "400px" }}>
{method === MfaMethod.EMAIL && (
<div className="flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mt-2 mb-2"
{...codeInputPropsPhone}
/>
</div>
)}
{method === MfaMethod.TOTP && (
<div className="mt-4 mb-6 flex justify-center">
<ReactCodeInput
key={showRecoveryCodeInput ? "recovery-mobile" : "totp-mobile"}
name=""
inputMode="tel"
type="text"
fields={showRecoveryCodeInput ? 8 : 6}
onChange={setMfaCode}
className="mb-2"
{...codeInputPropsPhone}
/>
</div>
)}
</div>
{typeof triesLeft === "number" && (
<Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} />
)} )}
</div> <div className="mx-auto mt-6 flex w-full max-w-sm flex-col items-center justify-center text-center">
{typeof triesLeft === "number" && ( <Button
<Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} /> size="md"
)} type="submit"
<div className="mx-auto mt-6 flex w-full max-w-sm flex-col items-center justify-center text-center"> isFullWidth
<Button className="h-11 rounded-lg font-medium shadow-xs transition-all duration-200 hover:shadow-md"
size="md" colorSchema="primary"
type="submit" variant="outline_bg"
isFullWidth isLoading={isLoading}
className="h-11 rounded-lg font-medium shadow-xs transition-all duration-200 hover:shadow-md" isDisabled={!isCodeComplete || (typeof triesLeft === "number" && triesLeft <= 0)}
colorSchema="primary" >
variant="outline_bg" {String(t("mfa.verify"))}
isLoading={isLoading} </Button>
isDisabled={!isCodeComplete || (typeof triesLeft === "number" && triesLeft <= 0)} </div>
> </form>
{String(t("mfa.verify"))} )}
</Button>
</div>
</form>
{method === MfaMethod.TOTP && ( {method === MfaMethod.TOTP && (
<div className="mt-6 flex flex-col items-center gap-4 text-sm"> <div className="mt-6 flex flex-col items-center gap-4 text-sm">
<button <button

View File

@@ -28,12 +28,12 @@ const SCOPE_BADGE: Record<NonNullable<Props["scope"]>, { icon: LucideIcon; class
}; };
export const PageHeader = ({ title, description, children, className, scope }: Props) => ( export const PageHeader = ({ title, description, children, className, scope }: Props) => (
<div className={twMerge("mb-10 w-full", className)}> <div className={twMerge("mb-10 w-full border-b border-border pb-8", className)}>
<div className="flex w-full justify-between"> <div className="flex w-full justify-between">
<div className="mr-4 flex min-w-0 flex-1 items-center"> <div className="mr-4 flex min-w-0 flex-1 items-center">
<h1 <h1
className={twMerge( className={twMerge(
"truncate text-2xl font-medium text-white underline decoration-2 underline-offset-4", "truncate text-2xl font-medium text-white underline underline-offset-4",
scope === "org" && "decoration-org/90", scope === "org" && "decoration-org/90",
scope === "instance" && "decoration-neutral/90", scope === "instance" && "decoration-neutral/90",
scope === "namespace" && "decoration-sub-org/90", scope === "namespace" && "decoration-sub-org/90",

View File

@@ -10,7 +10,7 @@ function UnstableAccordion({ ...props }: React.ComponentProps<typeof AccordionPr
return ( return (
<AccordionPrimitive.Root <AccordionPrimitive.Root
data-slot="accordion" data-slot="accordion"
className="overflow-clip rounded-md border border-border bg-container" className="overflow-clip rounded-md border border-border bg-container text-foreground"
{...props} {...props}
/> />
); );

View File

@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "cva";
import { cn } from "../../utils"; import { cn } from "../../utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", "relative w-full rounded-md border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -51,6 +51,7 @@ function UnstableTableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"border-b border-border transition-colors hover:bg-foreground/5 data-[state=selected]:bg-foreground/5", "border-b border-border transition-colors hover:bg-foreground/5 data-[state=selected]:bg-foreground/5",
props.onClick && "cursor-pointer",
className className
)} )}
{...props} {...props}

View File

@@ -11,11 +11,11 @@ export {
ProjectPermissionGroupActions, ProjectPermissionGroupActions,
ProjectPermissionIdentityActions, ProjectPermissionIdentityActions,
ProjectPermissionKmipActions, ProjectPermissionKmipActions,
ProjectPermissionMcpEndpointActions,
ProjectPermissionMemberActions, ProjectPermissionMemberActions,
ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiSyncActions, ProjectPermissionPkiSyncActions,
ProjectPermissionPkiTemplateActions, ProjectPermissionPkiTemplateActions,
ProjectPermissionSshHostActions, ProjectPermissionSshHostActions,
ProjectPermissionMcpEndpointActions,
ProjectPermissionSub ProjectPermissionSub
} from "./types"; } from "./types";

View File

@@ -22,12 +22,12 @@ export {
ProjectPermissionGroupActions, ProjectPermissionGroupActions,
ProjectPermissionIdentityActions, ProjectPermissionIdentityActions,
ProjectPermissionKmipActions, ProjectPermissionKmipActions,
ProjectPermissionMcpEndpointActions,
ProjectPermissionMemberActions, ProjectPermissionMemberActions,
ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiSyncActions, ProjectPermissionPkiSyncActions,
ProjectPermissionPkiTemplateActions, ProjectPermissionPkiTemplateActions,
ProjectPermissionSshHostActions, ProjectPermissionSshHostActions,
ProjectPermissionMcpEndpointActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission useProjectPermission
} from "./ProjectPermissionContext"; } from "./ProjectPermissionContext";

View File

@@ -0,0 +1,16 @@
import { MfaMethod } from "@app/hooks/api/auth/types";
export type TMfaSessionError = {
name: "SESSION_MFA_REQUIRED";
message: string;
error: {
mfaSessionId: string;
mfaMethod: MfaMethod;
};
};
export const isMfaSessionError = (
error: any
): error is { response: { data: TMfaSessionError } } => {
return error?.response?.data?.name === "SESSION_MFA_REQUIRED";
};

View File

@@ -7,6 +7,7 @@ import { SessionStorageKeys } from "@app/const";
import { organizationKeys } from "../organization/queries"; import { organizationKeys } from "../organization/queries";
import { projectKeys } from "../projects"; import { projectKeys } from "../projects";
import { setAuthToken } from "../reactQuery"; import { setAuthToken } from "../reactQuery";
import { TGenerateAuthenticationOptionsResponse, TVerifyAuthenticationDTO } from "../webauthn";
import { import {
CompleteAccountDTO, CompleteAccountDTO,
CompleteAccountSignupDTO, CompleteAccountSignupDTO,
@@ -333,6 +334,36 @@ export const checkUserTotpMfa = async () => {
return data.isVerified; return data.isVerified;
}; };
export const checkUserWebAuthnMfa = async () => {
const { data } = await apiRequest.get<{ hasPasskeys: boolean }>(
"/api/v2/auth/mfa/check/webauthn"
);
return data.hasPasskeys;
};
export const useMfaGenerateAuthenticationOptions = () =>
useMutation({
mutationFn: async () => {
const { data } = await apiRequest.post<TGenerateAuthenticationOptionsResponse>(
"/api/v2/auth/mfa/webauthn/authenticate"
);
return data;
}
});
export const useMfaVerifyAuthentication = () =>
useMutation({
mutationFn: async (dto: TVerifyAuthenticationDTO) => {
const { data } = await apiRequest.post<{
verified: boolean;
credentialId: string;
sessionToken: string;
}>("/api/v2/auth/mfa/webauthn/verify", dto);
return data;
}
});
export const useSendPasswordSetupEmail = () => { export const useSendPasswordSetupEmail = () => {
return useMutation({ return useMutation({
mutationFn: async () => { mutationFn: async () => {

View File

@@ -149,5 +149,6 @@ export enum UserAgentType {
export enum MfaMethod { export enum MfaMethod {
EMAIL = "email", EMAIL = "email",
TOTP = "totp" TOTP = "totp",
WEBAUTHN = "webauthn"
} }

View File

@@ -28,6 +28,7 @@ export * from "./integrationAuth";
export * from "./integrations"; export * from "./integrations";
export * from "./kms"; export * from "./kms";
export * from "./ldapConfig"; export * from "./ldapConfig";
export * from "./mfaSession";
export * from "./oidcConfig"; export * from "./oidcConfig";
export * from "./orgAdmin"; export * from "./orgAdmin";
export * from "./organization"; export * from "./organization";

View File

@@ -0,0 +1,7 @@
export { useMfaSessionStatus, useVerifyMfaSession } from "./queries";
export type {
TMfaSessionStatusResponse,
TVerifyMfaSessionRequest,
TVerifyMfaSessionResponse
} from "./types";
export { MfaSessionStatus } from "./types";

View File

@@ -0,0 +1,45 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import {
MfaSessionStatus,
TMfaSessionStatusResponse,
TVerifyMfaSessionRequest,
TVerifyMfaSessionResponse
} from "./types";
export const useMfaSessionStatus = (mfaSessionId: string, enabled = true) => {
return useQuery({
queryKey: ["mfa-session-status", mfaSessionId],
queryFn: async () => {
const { data } = await apiRequest.get<TMfaSessionStatusResponse>(
`/api/v2/mfa-sessions/${mfaSessionId}/status`
);
return data;
},
enabled,
refetchInterval: (query) => {
// Poll every 2 seconds if status is still PENDING
if (query.state.data?.status === MfaSessionStatus.PENDING) {
return 2000;
}
return false;
}
});
};
export const useVerifyMfaSession = () => {
return useMutation({
mutationFn: async ({ mfaSessionId, mfaToken, mfaMethod }: TVerifyMfaSessionRequest) => {
const { data } = await apiRequest.post<TVerifyMfaSessionResponse>(
`/api/v2/mfa-sessions/${mfaSessionId}/verify`,
{
mfaToken,
mfaMethod
}
);
return data;
}
});
};

View File

@@ -0,0 +1,22 @@
import { MfaMethod } from "../auth/types";
export enum MfaSessionStatus {
PENDING = "PENDING",
ACTIVE = "ACTIVE"
}
export type TMfaSessionStatusResponse = {
status: MfaSessionStatus;
mfaMethod: MfaMethod;
};
export type TVerifyMfaSessionRequest = {
mfaSessionId: string;
mfaToken: string;
mfaMethod: MfaMethod;
};
export type TVerifyMfaSessionResponse = {
success: boolean;
message: string;
};

View File

@@ -14,7 +14,8 @@ export interface TBasePamAccount {
name: string; name: string;
description?: string | null; description?: string | null;
rotationEnabled: boolean; rotationEnabled: boolean;
rotationIntervalSeconds?: number | null; rotationIntervalSeconds?: number;
requireMfa?: boolean | null;
lastRotatedAt?: string | null; lastRotatedAt?: string | null;
lastRotationMessage?: string | null; lastRotationMessage?: string | null;
rotationStatus?: string | null; rotationStatus?: string | null;

View File

@@ -143,13 +143,13 @@ export type TListPamAccountsDTO = {
export type TCreatePamAccountDTO = Pick< export type TCreatePamAccountDTO = Pick<
TPamAccount, TPamAccount,
"name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId" "name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId" | "requireMfa"
> & { > & {
resourceType: PamResourceType; resourceType: PamResourceType;
}; };
export type TUpdatePamAccountDTO = Partial< export type TUpdatePamAccountDTO = Partial<
Pick<TPamAccount, "name" | "description" | "credentials"> Pick<TPamAccount, "name" | "description" | "credentials" | "requireMfa">
> & { > & {
accountId: string; accountId: string;
resourceType: PamResourceType; resourceType: PamResourceType;

View File

@@ -0,0 +1,18 @@
export {
useDeleteWebAuthnCredential,
useGenerateAuthenticationOptions,
useGenerateRegistrationOptions,
useUpdateWebAuthnCredential,
useVerifyAuthentication,
useVerifyRegistration
} from "./mutations";
export { useGetWebAuthnCredentials } from "./queries";
export type {
TDeleteWebAuthnCredentialDTO,
TGenerateAuthenticationOptionsResponse,
TGenerateRegistrationOptionsResponse,
TUpdateWebAuthnCredentialDTO,
TVerifyAuthenticationDTO,
TVerifyRegistrationDTO,
TWebAuthnCredential
} from "./types";

View File

@@ -0,0 +1,96 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { webAuthnKeys } from "./queries";
import {
TDeleteWebAuthnCredentialDTO,
TGenerateAuthenticationOptionsResponse,
TGenerateRegistrationOptionsResponse,
TUpdateWebAuthnCredentialDTO,
TVerifyAuthenticationDTO,
TVerifyRegistrationDTO
} from "./types";
export const useGenerateRegistrationOptions = () =>
useMutation({
mutationFn: async () => {
const { data } = await apiRequest.post<TGenerateRegistrationOptionsResponse>(
"/api/v1/user/me/webauthn/register"
);
return data;
}
});
export const useVerifyRegistration = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (dto: TVerifyRegistrationDTO) => {
const { data } = await apiRequest.post<{ credentialId: string; name?: string | null }>(
"/api/v1/user/me/webauthn/register/verify",
dto
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: webAuthnKeys.credentials });
}
});
};
export const useGenerateAuthenticationOptions = () =>
useMutation({
mutationFn: async () => {
const { data } = await apiRequest.post<TGenerateAuthenticationOptionsResponse>(
"/api/v1/user/me/webauthn/authenticate"
);
return data;
}
});
export const useVerifyAuthentication = () =>
useMutation({
mutationFn: async (dto: TVerifyAuthenticationDTO) => {
const { data } = await apiRequest.post<{
verified: boolean;
credentialId: string;
sessionToken: string;
}>("/api/v1/user/me/webauthn/authenticate/verify", dto);
return data;
}
});
export const useDeleteWebAuthnCredential = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id }: TDeleteWebAuthnCredentialDTO) => {
const { data } = await apiRequest.delete<{ success: boolean }>(
`/api/v1/user/me/webauthn/${id}`
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: webAuthnKeys.credentials });
}
});
};
export const useUpdateWebAuthnCredential = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, name }: TUpdateWebAuthnCredentialDTO) => {
const { data } = await apiRequest.patch<{
id: string;
credentialId: string;
name?: string | null;
}>(`/api/v1/user/me/webauthn/${id}`, { name });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: webAuthnKeys.credentials });
}
});
};

View File

@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TWebAuthnCredential } from "./types";
export const webAuthnKeys = {
credentials: ["webauthn-credentials"] as const,
registrationOptions: ["webauthn-registration-options"] as const,
authenticationOptions: ["webauthn-authentication-options"] as const
};
export const useGetWebAuthnCredentials = () =>
useQuery({
queryKey: webAuthnKeys.credentials,
queryFn: async () => {
const { data } = await apiRequest.get<{ credentials: TWebAuthnCredential[] }>(
"/api/v1/user/me/webauthn"
);
return data.credentials;
}
});

View File

@@ -0,0 +1,37 @@
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON
} from "@simplewebauthn/browser";
export type TWebAuthnCredential = {
id: string;
credentialId: string;
name?: string | null;
transports?: string[] | null;
createdAt: Date;
lastUsedAt?: Date | null;
};
export type TGenerateRegistrationOptionsResponse = PublicKeyCredentialCreationOptionsJSON;
export type TVerifyRegistrationDTO = {
registrationResponse: RegistrationResponseJSON;
name?: string;
};
export type TGenerateAuthenticationOptionsResponse = PublicKeyCredentialRequestOptionsJSON;
export type TVerifyAuthenticationDTO = {
authenticationResponse: AuthenticationResponseJSON;
};
export type TUpdateWebAuthnCredentialDTO = {
id: string;
name?: string;
};
export type TDeleteWebAuthnCredentialDTO = {
id: string;
};

View File

@@ -0,0 +1,311 @@
import { useEffect, useState } from "react";
import ReactCodeInput from "react-code-input";
import { startAuthentication } from "@simplewebauthn/browser";
import { useParams } from "@tanstack/react-router";
import Error from "@app/components/basic/Error";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { MfaMethod } from "@app/hooks/api/auth/types";
import {
MfaSessionStatus,
useMfaSessionStatus,
useVerifyMfaSession
} from "@app/hooks/api/mfaSession";
import { useGenerateAuthenticationOptions, useVerifyAuthentication } from "@app/hooks/api/webauthn";
const codeInputProps = {
inputStyle: {
fontFamily: "monospace",
margin: "4px",
MozAppearance: "textfield",
width: "55px",
borderRadius: "5px",
fontSize: "24px",
height: "55px",
paddingLeft: "7",
backgroundColor: "#0d1117",
color: "white",
border: "1px solid #2d2f33",
textAlign: "center",
outlineColor: "#8ca542",
borderColor: "#2d2f33"
}
} as const;
const codeInputPropsPhone = {
inputStyle: {
fontFamily: "monospace",
margin: "4px",
MozAppearance: "textfield",
width: "40px",
borderRadius: "5px",
fontSize: "24px",
height: "40px",
paddingLeft: "7",
backgroundColor: "#0d1117",
color: "white",
border: "1px solid #2d2f33",
textAlign: "center",
outlineColor: "#8ca542",
borderColor: "#2d2f33"
}
} as const;
export const MfaSessionPage = () => {
const { mfaSessionId } = useParams({ strict: false }) as { mfaSessionId: string };
const [mfaCode, setMfaCode] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: sessionStatus, isError: isStatusError } = useMfaSessionStatus(mfaSessionId);
const verifyMfaSession = useVerifyMfaSession();
const { mutateAsync: generateWebAuthnAuthenticationOptions } = useGenerateAuthenticationOptions();
const { mutateAsync: verifyWebAuthnAuthentication } = useVerifyAuthentication();
// Show notification and auto-close when MFA is completed
useEffect(() => {
if (sessionStatus?.status === MfaSessionStatus.ACTIVE) {
createNotification({
text: "MFA verification successful! Closing window...",
type: "success"
});
// Auto-close window after showing success message
setTimeout(() => {
window.close();
}, 1000);
}
}, [sessionStatus?.status]);
// Handle status error (session not found or expired)
useEffect(() => {
if (isStatusError) {
setError("MFA session not found or expired. Please try again.");
}
}, [isStatusError]);
const getExpectedCodeLength = () => {
if (sessionStatus?.mfaMethod === MfaMethod.EMAIL) return 6;
if (sessionStatus?.mfaMethod === MfaMethod.TOTP) return 6;
return 6;
};
const isCodeComplete = mfaCode.length === getExpectedCodeLength();
const handleVerifyMfa = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!mfaCode.trim() || !isCodeComplete || !sessionStatus?.mfaMethod) return;
setIsLoading(true);
setError(null);
try {
await verifyMfaSession.mutateAsync({
mfaSessionId,
mfaToken: mfaCode.trim(),
mfaMethod: sessionStatus.mfaMethod
});
createNotification({
text: "MFA verification successful! Closing window...",
type: "success"
});
// Auto-close window after showing success message
setTimeout(() => {
window.close();
}, 1000);
} catch (err: any) {
setError(err?.response?.data?.message || "Invalid MFA code. Please try again.");
setMfaCode("");
} finally {
setIsLoading(false);
}
};
const handleWebAuthnVerification = async () => {
if (!sessionStatus?.mfaMethod) return;
setIsLoading(true);
setError(null);
try {
// Get authentication options from server
const options = await generateWebAuthnAuthenticationOptions();
// Prompt user to authenticate with their passkey
const authenticationResponse = await startAuthentication({ optionsJSON: options });
// Verify with server to get session token
const result = await verifyWebAuthnAuthentication({ authenticationResponse });
// Use the sessionToken to verify MFA session
if (result.sessionToken) {
await verifyMfaSession.mutateAsync({
mfaSessionId,
mfaToken: result.sessionToken,
mfaMethod: MfaMethod.WEBAUTHN
});
createNotification({
text: "MFA verification successful! Closing window...",
type: "success"
});
// Auto-close window after showing success message
setTimeout(() => {
window.close();
}, 1000);
}
} catch (err: any) {
console.error("WebAuthn verification failed:", err);
let errorMessage = "Failed to verify passkey";
if (err.name === "NotAllowedError") {
errorMessage = "Passkey verification was cancelled or timed out";
} else if (err?.response?.data?.message) {
errorMessage = err.response.data.message;
} else if (err.message) {
errorMessage = err.message;
}
setError(errorMessage);
} finally {
setIsLoading(false);
}
};
if (isStatusError) {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-bunker-800 bg-linear-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<div className="mx-auto w-max pt-6 pb-6 md:mb-16 md:px-8">
<div className="mb-4 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
<div className="mb-6 text-center">
<h2 className="mb-3 text-xl font-medium text-red-400">Session Expired</h2>
<p className="text-bunker-300">
This MFA session has expired or is invalid. Please try your action again.
</p>
</div>
</div>
</div>
);
}
if (!sessionStatus) {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-bunker-800 bg-linear-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<div className="text-center">
<div className="mb-4 text-bunker-300">Loading...</div>
</div>
</div>
);
}
if (sessionStatus.status === MfaSessionStatus.ACTIVE) {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-bunker-800 bg-linear-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<div className="mx-auto w-max pt-6 pb-6 md:mb-16 md:px-8">
<div className="mb-4 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
<div className="mb-6 text-center">
<h2 className="mb-3 text-xl font-medium text-bunker-50">Verification Complete</h2>
<p className="text-bunker-300">This window will close automatically...</p>
</div>
</div>
</div>
);
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-bunker-800 bg-linear-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<div className="mx-auto w-max pt-6 pb-6 md:mb-16 md:px-8">
<div className="mb-4 flex justify-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
<div className="mb-8 text-center">
<h2 className="mb-3 text-xl font-medium text-bunker-100">Two-Factor Authentication</h2>
<p className="mx-auto max-w-md text-sm leading-relaxed text-bunker-300">
{sessionStatus.mfaMethod === MfaMethod.EMAIL &&
"Enter the verification code sent to your email"}
{sessionStatus.mfaMethod === MfaMethod.TOTP &&
"Enter the verification code from your authenticator app"}
{sessionStatus.mfaMethod === MfaMethod.WEBAUTHN &&
"Use your registered passkey to complete two-factor authentication"}
</p>
</div>
{sessionStatus.mfaMethod === MfaMethod.WEBAUTHN ? (
<>
{error && <Error text={error} />}
<div className="mx-auto mt-6 flex w-full max-w-sm flex-col items-center justify-center text-center">
<Button
size="md"
onClick={handleWebAuthnVerification}
isFullWidth
className="h-11 rounded-lg font-medium shadow-xs transition-all duration-200 hover:shadow-md"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
>
Authenticate with Passkey
</Button>
</div>
</>
) : (
<form onSubmit={handleVerifyMfa}>
<div className="mx-auto hidden md:block" style={{ minWidth: "600px" }}>
<div className="mt-8 mb-6 flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={getExpectedCodeLength()}
onChange={setMfaCode}
value={mfaCode}
className="mb-2"
{...codeInputProps}
/>
</div>
</div>
<div className="mx-auto mt-4 block md:hidden" style={{ minWidth: "400px" }}>
<div className="mt-4 mb-6 flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={getExpectedCodeLength()}
onChange={setMfaCode}
value={mfaCode}
className="mb-2"
{...codeInputPropsPhone}
/>
</div>
</div>
{error && <Error text={error} />}
<div className="mx-auto mt-6 flex w-full max-w-sm flex-col items-center justify-center text-center">
<Button
size="md"
type="submit"
isFullWidth
className="h-11 rounded-lg font-medium shadow-xs transition-all duration-200 hover:shadow-md"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
isDisabled={!isCodeComplete}
>
Verify
</Button>
</div>
</form>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { MfaSessionPage } from "./MfaSessionPage";

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { MfaSessionPage } from "./MfaSessionPage";
export const Route = createFileRoute("/_authenticate/mfa-session/$mfaSessionId")({
component: MfaSessionPage
});

View File

@@ -236,7 +236,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
<Table> <Table>
<THead> <THead>
<Tr className="h-14"> <Tr className="h-14">
<Th className="w-1/2"> <Th className="w-2/3">
<div className="flex items-center"> <div className="flex items-center">
Name Name
<IconButton <IconButton
@@ -309,7 +309,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
}) })
} }
> >
<Td className="group"> <Td className="group max-w-0 truncate">
{name} {name}
{lastLoginAuthMethod && lastLoginTime && ( {lastLoginAuthMethod && lastLoginTime && (
<Tooltip <Tooltip

View File

@@ -1,13 +1,28 @@
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { ChevronLeftIcon, EllipsisIcon } from "lucide-react";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, Modal, ModalContent, PageHeader } from "@app/components/v2"; import { DeleteActionModal, Modal, ModalContent, PageHeader } from "@app/components/v2";
import {
OrgIcon,
UnstableAlert,
UnstableAlertDescription,
UnstableAlertTitle,
UnstableButton,
UnstableCard,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger
} from "@app/components/v3";
import { ROUTE_PATHS } from "@app/const/routes"; import { ROUTE_PATHS } from "@app/const/routes";
import { import {
OrgPermissionActions, OrgPermissionActions,
@@ -36,7 +51,7 @@ const Page = () => {
const { currentOrg, isSubOrganization } = useOrganization(); const { currentOrg, isSubOrganization } = useOrganization();
const orgId = currentOrg?.id || ""; const orgId = currentOrg?.id || "";
const { data } = useGetOrgIdentityMembershipById(identityId); const { data } = useGetOrgIdentityMembershipById(identityId);
const { mutateAsync: deleteIdentity, isPending: isDeletingIdentity } = useDeleteOrgIdentity(); const { mutateAsync: deleteIdentity } = useDeleteOrgIdentity();
const isAuthHidden = orgId !== data?.identity?.orgId; const isAuthHidden = orgId !== data?.identity?.orgId;
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
@@ -68,41 +83,55 @@ const Page = () => {
}); });
}; };
const isScopeIdentity = data?.identity.orgId === currentOrg.id;
return ( return (
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white"> <div className="mx-auto flex max-w-8xl flex-col">
{data && ( {data && (
<div className="mx-auto w-full max-w-8xl"> <>
<Link <Link
to="/organizations/$orgId/access-management" to="/organizations/$orgId/access-management"
params={{ orgId }} params={{ orgId }}
search={{ search={{
selectedTab: OrgAccessControlTabSections.Identities selectedTab: OrgAccessControlTabSections.Identities
}} }}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400" className="mb-4 flex w-fit items-center gap-x-1 text-sm text-mineshaft-400 transition duration-100 hover:text-mineshaft-400/80"
> >
<FontAwesomeIcon icon={faChevronLeft} /> <ChevronLeftIcon size={16} />
Organization Machine Identities {isSubOrganization ? "Sub-" : ""}Organization Machine Identities
</Link> </Link>
<PageHeader <PageHeader
scope={isSubOrganization ? "namespace" : "org"} scope={isSubOrganization ? "namespace" : "org"}
description={`${isSubOrganization ? "Sub-" : ""}Organization Machine Identity`} description={`Configure and manage${isScopeIdentity ? " machine identity and " : " "}${isSubOrganization ? "sub-" : ""}organization access control`}
title={data.identity.name} title={data.identity.name}
> >
<div className="flex items-center gap-2"> <UnstableDropdownMenu>
{isSubOrganization && data.identity.orgId !== currentOrg.id && ( <UnstableDropdownMenuTrigger asChild>
<UnstableButton variant="outline">
Options
<EllipsisIcon />
</UnstableButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(data.identity.id);
createNotification({
text: "Machine identity ID copied to clipboard",
type: "info"
});
}}
>
Copy Machine Identity ID
</UnstableDropdownMenuItem>
<OrgPermissionCan <OrgPermissionCan
I={OrgPermissionActions.Delete} I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Identity} a={OrgPermissionSubjects.Identity}
renderTooltip
allowedLabel="Remove from sub-organization"
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <UnstableDropdownMenuItem
colorSchema="danger" variant="danger"
variant="outline_bg"
size="xs"
isDisabled={!isAllowed} isDisabled={!isAllowed}
isLoading={isDeletingIdentity}
onClick={() => onClick={() =>
handlePopUpOpen("deleteIdentity", { handlePopUpOpen("deleteIdentity", {
identityId: data.identity.id, identityId: data.identity.id,
@@ -110,23 +139,64 @@ const Page = () => {
}) })
} }
> >
Unlink Machine Identity {isScopeIdentity ? "Delete Machine Identity" : "Remove From Sub-Organization"}
</Button> </UnstableDropdownMenuItem>
)} )}
</OrgPermissionCan> </OrgPermissionCan>
)} </UnstableDropdownMenuContent>
</div> </UnstableDropdownMenu>
</PageHeader> </PageHeader>
<div className="flex flex-col gap-4 md:flex-row"> <div className="flex flex-col gap-5 lg:flex-row">
<div className="w-full md:w-96"> <IdentityDetailsSection
<IdentityDetailsSection isCurrentOrgIdentity={data.identity.orgId === currentOrg.id}
isOrgIdentity={data.identity.orgId === currentOrg.id} identityId={identityId}
identityId={identityId} handlePopUpOpen={handlePopUpOpen}
handlePopUpOpen={handlePopUpOpen} />
/> <div className="flex flex-1 flex-col gap-y-5">
</div> {isAuthHidden ? (
<div className="flex flex-1 flex-col gap-y-4"> <UnstableCard>
{!isAuthHidden && ( <UnstableCardHeader>
<UnstableCardTitle>Authentication</UnstableCardTitle>
<UnstableCardDescription>
Configure authentication methods
</UnstableCardDescription>
</UnstableCardHeader>
<UnstableCardContent>
<UnstableAlert variant="org">
<OrgIcon />
<UnstableAlertTitle>
Machine identity managed by organization
</UnstableAlertTitle>
<UnstableAlertDescription>
<p>
This machine identity&apos;s authentication methods are managed by your
organization. <br /> To make changes,{" "}
<OrgPermissionCan
I={OrgPermissionIdentityActions.Read}
an={OrgPermissionSubjects.Identity}
>
{(isAllowed) =>
isAllowed ? (
<Link
to="/organizations/$orgId/identities/$identityId"
className="inline-block cursor-pointer text-foreground underline underline-offset-2"
params={{
identityId,
orgId: data.identity.orgId
}}
>
go to organization access control
</Link>
) : null
}
</OrgPermissionCan>
.
</p>
</UnstableAlertDescription>
</UnstableAlert>
</UnstableCardContent>
</UnstableCard>
) : (
<IdentityAuthenticationSection <IdentityAuthenticationSection
identityId={identityId} identityId={identityId}
handlePopUpOpen={handlePopUpOpen} handlePopUpOpen={handlePopUpOpen}
@@ -135,7 +205,7 @@ const Page = () => {
<IdentityProjectsSection identityId={identityId} /> <IdentityProjectsSection identityId={identityId} />
</div> </div>
</div> </div>
</div> </>
)} )}
<Modal <Modal
isOpen={popUp?.identity?.isOpen} isOpen={popUp?.identity?.isOpen}

View File

@@ -1,9 +1,21 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { PlusIcon } from "lucide-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2"; import {
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; UnstableButton,
UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableEmpty,
UnstableEmptyContent,
UnstableEmptyDescription,
UnstableEmptyHeader,
UnstableEmptyTitle
} from "@app/components/v3";
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { IdentityAuthMethod, useGetOrgIdentityMembershipById } from "@app/hooks/api"; import { IdentityAuthMethod, useGetOrgIdentityMembershipById } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -20,51 +32,92 @@ type Props = {
export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: Props) => { export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: Props) => {
const { data, refetch } = useGetOrgIdentityMembershipById(identityId); const { data, refetch } = useGetOrgIdentityMembershipById(identityId);
const { isSubOrganization } = useOrganization();
const hasAuthMethods = Boolean(data?.identity.authMethods.length);
return data ? ( return data ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <UnstableCard>
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4"> <UnstableCardHeader>
<h3 className="text-lg font-medium text-mineshaft-100">Authentication</h3> <UnstableCardTitle>Authentication</UnstableCardTitle>
{!Object.values(IdentityAuthMethod).every((method) => <UnstableCardDescription>Configure authentication methods</UnstableCardDescription>
data.identity.authMethods.includes(method) {hasAuthMethods &&
) && ( !Object.values(IdentityAuthMethod).every((method) =>
<OrgPermissionCan data.identity.authMethods.includes(method)
I={OrgPermissionIdentityActions.Edit} ) && (
a={OrgPermissionSubjects.Identity} <UnstableCardAction>
> <OrgPermissionCan
{(isAllowed) => ( I={OrgPermissionIdentityActions.Edit}
<Button a={OrgPermissionSubjects.Identity}
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("identityAuthMethod", {
identityId,
name: data.identity.name,
allAuthMethods: data.identity.authMethods
});
}}
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
> >
{data.identity.authMethods.length ? "Add" : "Create"} Auth Method {(isAllowed) => (
</Button> <UnstableButton
)} variant="outline"
</OrgPermissionCan> isFullWidth
size="xs"
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("identityAuthMethod", {
identityId: data.identity.id,
name: data.identity.name,
allAuthMethods: data.identity.authMethods
});
}}
>
<PlusIcon />
Add Auth Method
</UnstableButton>
)}
</OrgPermissionCan>
</UnstableCardAction>
)}
</UnstableCardHeader>
<UnstableCardContent>
{data.identity.authMethods.length > 0 ? (
<ViewIdentityAuth
activeLockoutAuthMethods={data.identity.activeLockoutAuthMethods}
identityId={identityId}
authMethods={data.identity.authMethods}
onResetAllLockouts={refetch}
/>
) : (
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>
This machine identity has no auth methods configured
</UnstableEmptyTitle>
<UnstableEmptyDescription>
Add an auth method to use this machine identity
</UnstableEmptyDescription>
</UnstableEmptyHeader>
<UnstableEmptyContent>
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<UnstableButton
variant={isSubOrganization ? "sub-org" : "org"}
size="xs"
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("identityAuthMethod", {
identityId,
name: data.identity.name,
allAuthMethods: data.identity.authMethods
});
}}
>
<PlusIcon />
Add Auth Method
</UnstableButton>
)}
</OrgPermissionCan>
</UnstableEmptyContent>
</UnstableEmpty>
)} )}
</div> </UnstableCardContent>
{data.identity.authMethods.length > 0 ? ( </UnstableCard>
<ViewIdentityAuth
activeLockoutAuthMethods={data.identity.activeLockoutAuthMethods}
identityId={identityId}
authMethods={data.identity.authMethods}
onResetAllLockouts={refetch}
/>
) : (
<div className="w-full space-y-2 pt-2">
<p className="text-sm text-mineshaft-300">
No authentication methods configured. Get started by creating a new auth method.
</p>
</div>
)}
</div>
) : ( ) : (
<div /> <div />
); );

View File

@@ -1,26 +1,25 @@
import {
faCheck,
faChevronDown,
faCopy,
faEdit,
faKey,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns"; import { format } from "date-fns";
import { twMerge } from "tailwind-merge"; import { BanIcon, CheckIcon, ClipboardListIcon, PencilIcon } from "lucide-react";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
import { Tooltip } from "@app/components/v2";
import { import {
Button, Badge,
DropdownMenu, Detail,
DropdownMenuContent, DetailGroup,
DropdownMenuItem, DetailLabel,
DropdownMenuTrigger, DetailValue,
IconButton, OrgIcon,
Tag, SubOrgIcon,
Tooltip UnstableButtonGroup,
} from "@app/components/v2"; UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableIconButton
} from "@app/components/v3";
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useTimedReset } from "@app/hooks"; import { useTimedReset } from "@app/hooks";
import { identityAuthToNameMap, useGetOrgIdentityMembershipById } from "@app/hooks/api"; import { identityAuthToNameMap, useGetOrgIdentityMembershipById } from "@app/hooks/api";
@@ -28,187 +27,171 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
identityId: string; identityId: string;
isOrgIdentity?: boolean; isCurrentOrgIdentity?: boolean;
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState<["identity", "identityAuthMethod", "deleteIdentity"]>, popUpName: keyof UsePopUpState<["identity", "identityAuthMethod", "deleteIdentity"]>,
data?: object data?: object
) => void; ) => void;
}; };
export const IdentityDetailsSection = ({ identityId, handlePopUpOpen, isOrgIdentity }: Props) => { export const IdentityDetailsSection = ({
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({ identityId,
handlePopUpOpen,
isCurrentOrgIdentity
}: Props) => {
const [, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard" initialState: "Copy ID to clipboard"
}); });
const { isSubOrganization } = useOrganization(); const { isSubOrganization } = useOrganization();
const { data } = useGetOrgIdentityMembershipById(identityId); const { data } = useGetOrgIdentityMembershipById(identityId);
return data ? ( return data ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <UnstableCard className="w-full lg:max-w-[24rem]">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4"> <UnstableCardHeader className="border-b">
<h3 className="text-lg font-medium text-mineshaft-100">Details</h3> <UnstableCardTitle>Details</UnstableCardTitle>
<DropdownMenu> <UnstableCardDescription>Machine identity details</UnstableCardDescription>
<DropdownMenuTrigger asChild> <UnstableCardAction>
<Button <OrgPermissionCan
size="xs" I={OrgPermissionIdentityActions.Edit}
rightIcon={ a={OrgPermissionSubjects.Identity}
<FontAwesomeIcon >
className="ml-1 transition-transform duration-200 group-data-[state=open]:rotate-180" {(isAllowed) => (
icon={faChevronDown} <UnstableIconButton
/> isDisabled={!isAllowed}
} onClick={() => {
colorSchema="secondary" handlePopUpOpen("identity", {
className="group select-none" identityId,
> name: data.identity.name,
Options orgId: data.identity.orgId,
</Button> hasDeleteProtection: data.identity.hasDeleteProtection,
</DropdownMenuTrigger> role: data.role,
<DropdownMenuContent className="mt-3 min-w-[120px]" align="end"> customRole: data.customRole,
<OrgPermissionCan metadata: data.metadata
I={OrgPermissionIdentityActions.Edit} });
a={OrgPermissionSubjects.Identity} }}
> size="xs"
{(isAllowed) => ( variant="outline"
<DropdownMenuItem >
className={twMerge( <PencilIcon />
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50" </UnstableIconButton>
)} )}
icon={<FontAwesomeIcon icon={faEdit} />} </OrgPermissionCan>
onClick={async () => { </UnstableCardAction>
handlePopUpOpen("identity", { </UnstableCardHeader>
identityId, <UnstableCardContent>
name: data.identity.name, <DetailGroup>
orgId: data.identity.orgId, <Detail>
hasDeleteProtection: data.identity.hasDeleteProtection, <DetailLabel>Name</DetailLabel>
role: data.role, <DetailValue>{data.identity.name}</DetailValue>
customRole: data.customRole, </Detail>
metadata: data.metadata <Detail>
}); <DetailLabel>ID</DetailLabel>
}} <DetailValue className="flex items-center gap-x-1">
disabled={!isAllowed} {data.identity.id}
> <Tooltip content="Copy machine identity ID to clipboard">
{isOrgIdentity ? "Edit Machine Identity" : "Edit Machine Identity Role"} <UnstableIconButton
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionIdentityActions.Delete}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:bg-red-500! hover:text-white!"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
handlePopUpOpen("deleteIdentity", {
identityId,
name: data.identity.name
});
}}
icon={<FontAwesomeIcon icon={faTrash} />}
disabled={!isAllowed}
>
{!isOrgIdentity ? "Remove From Sub-Organization" : "Delete Machine Identity"}
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Machine Identity ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{data.identity.id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(data.identity.id); navigator.clipboard.writeText(data.identity.id);
setCopyTextId("Copied"); setCopyTextId("Copied");
}} }}
variant="ghost"
size="xs"
> >
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} /> {/* TODO(scott): color this should be a button variant and create re-usable copy button */}
</IconButton> {isCopyingId ? <CheckIcon /> : <ClipboardListIcon className="text-label" />}
</UnstableIconButton>
</Tooltip> </Tooltip>
</div> </DetailValue>
</div> </Detail>
</div> <Detail>
<div className="mb-4"> <DetailLabel>Managed by</DetailLabel>
<p className="text-sm font-medium text-mineshaft-300">Name</p> <DetailValue>
<p className="text-sm text-mineshaft-300">{data.identity.name}</p> {isSubOrganization && isCurrentOrgIdentity ? (
</div> <Badge variant="sub-org">
{isSubOrganization && ( <SubOrgIcon />
<div className="mb-4"> Sub-Organization
<p className="text-sm font-medium text-mineshaft-300">Managed By</p> </Badge>
<p className="text-sm text-mineshaft-300"> ) : (
{isOrgIdentity ? "Organization" : "Root Organization"} <Badge variant="org">
</p> <OrgIcon />
</div> Organization
)} </Badge>
{isOrgIdentity && ( )}
<div className="mb-4"> </DetailValue>
<p className="text-sm font-medium text-mineshaft-300">Last Login Auth Method</p> </Detail>
<p className="text-sm text-mineshaft-300"> <Detail>
{data.lastLoginAuthMethod ? identityAuthToNameMap[data.lastLoginAuthMethod] : "-"} <DetailLabel>Organization Role</DetailLabel>
</p> <DetailValue>{data.role}</DetailValue>
</div> </Detail>
)} <Detail>
{isOrgIdentity && ( <DetailLabel>Metadata</DetailLabel>
<div className="mb-4"> <DetailValue className="flex flex-wrap gap-2">
<p className="text-sm font-medium text-mineshaft-300">Last Login Time</p> {data?.metadata?.length ? (
<p className="text-sm text-mineshaft-300"> data.metadata?.map((el) => (
{data.lastLoginTime ? format(data.lastLoginTime, "PPpp") : "-"} <UnstableButtonGroup className="min-w-0" key={el.id}>
</p> <Badge isTruncatable>
</div> <span>{el.key}</span>
)} </Badge>
{isOrgIdentity && ( <Badge variant="outline" isTruncatable>
<div className="mb-4"> <span>{el.value}</span>
<p className="text-sm font-medium text-mineshaft-300">Delete Protection</p> </Badge>
<p className="text-sm text-mineshaft-300"> </UnstableButtonGroup>
{data.identity.hasDeleteProtection ? "On" : "Off"} ))
</p> ) : (
</div> <span className="text-muted"></span>
)} )}
<div className="mb-4"> </DetailValue>
<p className="text-sm font-medium text-mineshaft-300">Organization Role</p> </Detail>
<p className="text-sm text-mineshaft-300">{data.role}</p> <Detail>
</div> <DetailLabel>
<div> {isSubOrganization && !isCurrentOrgIdentity ? "Joined sub-organization" : "Created"}
<p className="text-sm font-medium text-mineshaft-300">Metadata</p> </DetailLabel>
{data?.metadata?.length ? ( <DetailValue>{format(data.createdAt, "PPpp")}</DetailValue>
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300"> </Detail>
{data.metadata?.map((el) => ( {isCurrentOrgIdentity && (
<div key={el.id} className="flex items-center"> <>
<Tag <Detail>
size="xs" <DetailLabel>Last Login Method</DetailLabel>
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500" <DetailValue>
> {data.lastLoginAuthMethod ? (
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" /> identityAuthToNameMap[data.lastLoginAuthMethod]
<div>{el.key}</div> ) : (
</Tag> <span className="text-muted"></span>
<Tag )}
size="xs" </DetailValue>
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1" </Detail>
> <Detail>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap"> <DetailLabel>Last Logged In</DetailLabel>
{el.value} <DetailValue>
</div> {data.lastLoginTime ? (
</Tag> format(data.lastLoginTime, "PPpp")
</div> ) : (
))} <span className="text-muted"></span>
</div> )}
) : ( </DetailValue>
<p className="text-sm text-mineshaft-300">-</p> </Detail>
<Detail>
<DetailLabel>Delete protection</DetailLabel>
<DetailValue>
{data.identity.hasDeleteProtection ? (
<Badge variant="success">
<CheckIcon />
Enabled
</Badge>
) : (
<Badge variant="neutral">
<BanIcon />
Disabled
</Badge>
)}
</DetailValue>
</Detail>
</>
)} )}
</div> </DetailGroup>
</div> </UnstableCardContent>
</div> </UnstableCard>
) : ( ) : (
<div /> <div />
); );

View File

@@ -1,11 +1,18 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { format } from "date-fns"; import { format } from "date-fns";
import { MoreHorizontalIcon } from "lucide-react";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; import {
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
UnstableIconButton,
UnstableTableCell,
UnstableTableRow
} from "@app/components/v3";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { getProjectBaseURL } from "@app/helpers/project"; import { getProjectBaseURL } from "@app/helpers/project";
import { formatProjectRoleName } from "@app/helpers/roles"; import { formatProjectRoleName } from "@app/helpers/roles";
@@ -47,8 +54,7 @@ export const IdentityProjectRow = ({
}, [workspaces, project]); }, [workspaces, project]);
return ( return (
<Tr <UnstableTableRow
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`identity-project-membership-${id}`} key={`identity-project-membership-${id}`}
onClick={() => { onClick={() => {
if (isAccessible) { if (isAccessible) {
@@ -71,36 +77,55 @@ export const IdentityProjectRow = ({
}); });
}} }}
> >
<Td className="max-w-0 truncate">{project.name}</Td> <UnstableTableCell className="max-w-0 truncate">{project.name}</UnstableTableCell>
<Td>{`${formatProjectRoleName(roles[0].role, roles[0].customRoleName)}${ <UnstableTableCell>{`${formatProjectRoleName(roles[0].role, roles[0].customRoleName)}${
roles.length > 1 ? ` (+${roles.length - 1})` : "" roles.length > 1 ? ` (+${roles.length - 1})` : ""
}`}</Td> }`}</UnstableTableCell>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td> <UnstableTableCell>{format(new Date(createdAt), "yyyy-MM-dd")}</UnstableTableCell>
<Td> <UnstableTableCell>
{isAccessible && ( <UnstableDropdownMenu>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100"> <UnstableDropdownMenuTrigger>
<Tooltip content="Remove"> <UnstableIconButton variant="ghost" size="xs">
<IconButton <MoreHorizontalIcon />
colorSchema="danger" </UnstableIconButton>
ariaLabel="copy icon" </UnstableDropdownMenuTrigger>
variant="plain" <UnstableDropdownMenuContent align="end">
className="group relative" <UnstableDropdownMenuItem
onClick={(e) => { isDisabled={!isAccessible}
e.stopPropagation(); onClick={(e) => {
handlePopUpOpen("removeIdentityFromProject", { e.stopPropagation();
identityId: identity.id, navigate({
identityName: identity.name, to: `${getProjectBaseURL(project.type)}/access-management` as const,
projectId: project.id, params: {
projectName: project.name orgId: currentOrg?.id || "",
}); projectId: project.id
}} },
> search: {
<FontAwesomeIcon icon={faTrash} /> selectedTab: TabSections.Identities
</IconButton> }
</Tooltip> });
</div> }}
)} >
</Td> Access Project
</Tr> </UnstableDropdownMenuItem>
<UnstableDropdownMenuItem
variant="danger"
isDisabled={!isAccessible}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeIdentityFromProject", {
identityId: identity.id,
identityName: identity.name,
projectId: project.id,
projectName: project.name
});
}}
>
Remove From Project
</UnstableDropdownMenuItem>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</UnstableTableCell>
</UnstableTableRow>
); );
}; };

View File

@@ -1,9 +1,20 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { PlusIcon } from "lucide-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { DeleteActionModal, IconButton } from "@app/components/v2"; import { DeleteActionModal } from "@app/components/v2";
import { useDeleteProjectIdentityMembership } from "@app/hooks/api"; import {
UnstableButton,
UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle
} from "@app/components/v3";
import {
useDeleteProjectIdentityMembership,
useGetIdentityProjectMemberships
} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
import { IdentityAddToProjectModal } from "./IdentityAddToProjectModal"; import { IdentityAddToProjectModal } from "./IdentityAddToProjectModal";
@@ -35,24 +46,35 @@ export const IdentityProjectsSection = ({ identityId }: Props) => {
handlePopUpClose("removeIdentityFromProject"); handlePopUpClose("removeIdentityFromProject");
}; };
const { data: projectMemberships } = useGetIdentityProjectMemberships(identityId);
return ( return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4"> <UnstableCard>
<h3 className="text-lg font-medium text-mineshaft-100">Projects</h3> <UnstableCardHeader>
<IconButton <UnstableCardTitle>Projects</UnstableCardTitle>
ariaLabel="copy icon" <UnstableCardDescription>
variant="plain" Manage machine identity project memberships
className="group relative" </UnstableCardDescription>
onClick={() => { {Boolean(projectMemberships?.length) && (
handlePopUpOpen("addIdentityToProject"); <UnstableCardAction>
}} <UnstableButton
> onClick={() => {
<FontAwesomeIcon icon={faPlus} /> handlePopUpOpen("addIdentityToProject");
</IconButton> }}
</div> size="xs"
<div className="py-4"> variant="outline"
<IdentityProjectsTable identityId={identityId} handlePopUpOpen={handlePopUpOpen} /> >
</div> <PlusIcon />
Add to Project
</UnstableButton>
</UnstableCardAction>
)}
</UnstableCardHeader>
<UnstableCardContent>
<IdentityProjectsTable identityId={identityId} handlePopUpOpen={handlePopUpOpen} />
</UnstableCardContent>
</UnstableCard>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.removeIdentityFromProject.isOpen} isOpen={popUp.removeIdentityFromProject.isOpen}
title={`Are you sure you want to remove ${ title={`Are you sure you want to remove ${
@@ -76,6 +98,6 @@ export const IdentityProjectsSection = ({ identityId }: Props) => {
popUp={popUp} popUp={popUp}
handlePopUpToggle={handlePopUpToggle} handlePopUpToggle={handlePopUpToggle}
/> />
</div> </>
); );
}; };

View File

@@ -1,26 +1,24 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { import { ChevronDownIcon, PlusIcon } from "lucide-react";
faArrowDown, import { twMerge } from "tailwind-merge";
faArrowUp,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Lottie } from "@app/components/v2";
import { import {
EmptyState, UnstableButton,
IconButton, UnstableEmpty,
Input, UnstableEmptyContent,
Pagination, UnstableEmptyDescription,
Table, UnstableEmptyHeader,
TableContainer, UnstableEmptyTitle,
TableSkeleton, UnstableInput,
TBody, UnstablePagination,
Th, UnstableTable,
THead, UnstableTableBody,
Tr UnstableTableHead,
} from "@app/components/v2"; UnstableTableHeader,
UnstableTableRow
} from "@app/components/v3";
import { useOrganization } from "@app/context";
import { import {
getUserTablePreference, getUserTablePreference,
PreferenceKey, PreferenceKey,
@@ -36,7 +34,7 @@ import { IdentityProjectRow } from "./IdentityProjectRow";
type Props = { type Props = {
identityId: string; identityId: string;
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeIdentityFromProject"]>, popUpName: keyof UsePopUpState<["removeIdentityFromProject", "addIdentityToProject"]>,
data?: object data?: object
) => void; ) => void;
}; };
@@ -48,6 +46,8 @@ enum IdentityProjectsOrderBy {
export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => { export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => {
const { data: projectMemberships = [], isPending } = useGetIdentityProjectMemberships(identityId); const { data: projectMemberships = [], isPending } = useGetIdentityProjectMemberships(identityId);
const { isSubOrganization } = useOrganization();
const { const {
search, search,
setSearch, setSearch,
@@ -90,41 +90,43 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
setPage setPage
}); });
if (isPending) {
return (
// scott: todo proper loader
<div className="flex h-40 w-full items-center justify-center">
<Lottie icon="infisical_loading_white" isAutoPlay className="w-16" />
</div>
);
}
return ( return (
<div> <>
<Input <UnstableInput
className="mb-4"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search projects..." placeholder="Search projects..."
/> />
<TableContainer className="mt-4"> {filteredProjectMemberships.length ? (
<Table> <UnstableTable>
<THead> <UnstableTableHeader>
<Tr> <UnstableTableRow>
<Th className="w-2/3"> <UnstableTableHead onClick={toggleOrderDirection} className="w-1/3">
<div className="flex items-center"> Name
Name <ChevronDownIcon
<IconButton className={twMerge(
variant="plain" orderDirection === OrderByDirection.DESC && "rotate-180",
className="ml-2" "transition-transform"
ariaLabel="sort" )}
onClick={toggleOrderDirection} />
> </UnstableTableHead>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th> <UnstableTableHead>Role</UnstableTableHead>
<Th>Added On</Th> <UnstableTableHead>Added On</UnstableTableHead>
<Th className="w-5" /> <UnstableTableHead className="w-5" />
</Tr> </UnstableTableRow>
</THead> </UnstableTableHeader>
<TBody> <UnstableTableBody>
{isPending && <TableSkeleton columns={4} innerKey="identity-project-memberships" />}
{!isPending && {!isPending &&
filteredProjectMemberships.slice(offset, perPage * page).map((membership) => { filteredProjectMemberships.slice(offset, perPage * page).map((membership) => {
return ( return (
@@ -135,28 +137,45 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
/> />
); );
})} })}
</TBody> </UnstableTableBody>
</Table> </UnstableTable>
{Boolean(filteredProjectMemberships.length) && ( ) : (
<Pagination <UnstableEmpty className="border">
count={filteredProjectMemberships.length} <UnstableEmptyHeader>
page={page} <UnstableEmptyTitle>
perPage={perPage} {projectMemberships.length
onChangePage={setPage} ? "No projects match this search"
onChangePerPage={handlePerPageChange} : "This machine identity is not a member of any projects"}
/> </UnstableEmptyTitle>
)} <UnstableEmptyDescription>
{!isPending && !filteredProjectMemberships?.length && ( {projectMemberships.length
<EmptyState ? "Adjust search filters to view project memberships."
title={ : "Assign this machine identity to a project."}
projectMemberships.length </UnstableEmptyDescription>
? "No projects match search..." {!projectMemberships.length && (
: "This machine identity has not been assigned to any projects" <UnstableEmptyContent>
} <UnstableButton
icon={projectMemberships.length ? faSearch : faFolder} variant={isSubOrganization ? "sub-org" : "org"}
/> size="xs"
)} onClick={() => handlePopUpOpen("addIdentityToProject")}
</TableContainer> >
</div> <PlusIcon />
Add to Project
</UnstableButton>
</UnstableEmptyContent>
)}
</UnstableEmptyHeader>
</UnstableEmpty>
)}
{Boolean(filteredProjectMemberships.length) && (
<UnstablePagination
count={filteredProjectMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
</>
); );
}; };

View File

@@ -87,6 +87,9 @@ export const OrgGenericAuthSection = () => {
<SelectItem value={MfaMethod.TOTP} key="mfa-method-totp"> <SelectItem value={MfaMethod.TOTP} key="mfa-method-totp">
Mobile Authenticator Mobile Authenticator
</SelectItem> </SelectItem>
<SelectItem value={MfaMethod.WEBAUTHN} key="mfa-method-webauthn">
Passkey (WebAuthn)
</SelectItem>
</Select> </Select>
</FormControl> </FormControl>
)} )}

View File

@@ -1,22 +1,56 @@
import { LogInIcon, PackageOpenIcon } from "lucide-react"; import { useCallback } from "react";
import { faCheck, faCopy, faEdit, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { EllipsisIcon, LogInIcon, PackageOpenIcon } from "lucide-react";
import { Badge, UnstableButton } from "@app/components/v3"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/v2";
import { Badge, UnstableButton, UnstableIconButton } from "@app/components/v3";
import {
ProjectPermissionPamAccountActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { useToggle } from "@app/hooks";
import { PAM_RESOURCE_TYPE_MAP, TPamAccount } from "@app/hooks/api/pam"; import { PAM_RESOURCE_TYPE_MAP, TPamAccount } from "@app/hooks/api/pam";
type Props = { type Props = {
account: TPamAccount; account: TPamAccount;
onAccess: (resource: TPamAccount) => void; onAccess: (resource: TPamAccount) => void;
onUpdate: (resource: TPamAccount) => void;
onDelete: (resource: TPamAccount) => void;
accountPath?: string; accountPath?: string;
}; };
export const PamAccountCard = ({ account, onAccess, accountPath }: Props) => { export const PamAccountCard = ({ account, onAccess, accountPath, onUpdate, onDelete }: Props) => {
const { name, description, resource } = account; const { id, name, description, resource } = account;
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[account.resource.resourceType]; const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[account.resource.resourceType];
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(
(idToCopy: string) => {
setIsIdCopied.on();
navigator.clipboard.writeText(idToCopy);
createNotification({
text: "Account ID copied to clipboard",
type: "info"
});
setTimeout(() => setIsIdCopied.off(), 2000);
},
[setIsIdCopied]
);
return ( return (
<button <div
type="button"
key={account.id} key={account.id}
className="flex flex-col overflow-clip rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-4 text-start transition-transform duration-100" className="flex flex-col overflow-clip rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-4 text-start transition-transform duration-100"
> >
@@ -29,10 +63,58 @@ export const PamAccountCard = ({ account, onAccess, accountPath }: Props) => {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="truncate text-lg font-medium text-mineshaft-100">{name}</p> <p className="truncate text-lg font-medium text-mineshaft-100">{name}</p>
<UnstableButton onClick={() => onAccess(account)} size="xs" variant="outline"> <div className="flex items-center gap-2">
<LogInIcon /> <UnstableButton onClick={() => onAccess(account)} size="xs" variant="outline">
Connect <LogInIcon />
</UnstableButton> Connect
</UnstableButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<UnstableIconButton size="xs" variant="ghost">
<EllipsisIcon />
</UnstableIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={(e) => {
e.stopPropagation();
handleCopyId(id);
}}
>
Copy Account ID
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionPamAccountActions.Edit}
a={ProjectPermissionSub.PamAccounts}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={() => onUpdate(account)}
>
Edit Account
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionPamAccountActions.Delete}
a={ProjectPermissionSub.PamAccounts}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => onDelete(account)}
>
Delete Account
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
<p <p
@@ -47,6 +129,6 @@ export const PamAccountCard = ({ account, onAccess, accountPath }: Props) => {
{resource.name} {resource.name}
</Badge> </Badge>
<p className="mt-2 truncate text-sm text-mineshaft-400">{description || "No description"}</p> <p className="mt-2 truncate text-sm text-mineshaft-400">{description || "No description"}</p>
</button> </div>
); );
}; };

View File

@@ -9,6 +9,7 @@ import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
import { SqlAccountFields } from "./shared/SqlAccountFields"; import { SqlAccountFields } from "./shared/SqlAccountFields";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
import { RequireMfaField } from "./RequireMfaField";
type Props = { type Props = {
account?: TMySQLAccount; account?: TMySQLAccount;
@@ -21,7 +22,8 @@ const formSchema = genericAccountFieldsSchema.extend({
credentials: BaseSqlAccountSchema, credentials: BaseSqlAccountSchema,
// We don't support rotation for now, just feed a false value to // We don't support rotation for now, just feed a false value to
// make the schema happy // make the schema happy
rotationEnabled: z.boolean().default(false) rotationEnabled: z.boolean().default(false),
requireMfa: z.boolean().nullable().optional()
}); });
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
@@ -49,13 +51,10 @@ export const MySQLAccountForm = ({ account, onSubmit }: Props) => {
return ( return (
<FormProvider {...form}> <FormProvider {...form}>
<form <form onSubmit={handleSubmit(onSubmit)}>
onSubmit={(e) => {
handleSubmit(onSubmit)(e);
}}
>
<GenericAccountFields /> <GenericAccountFields />
<SqlAccountFields isUpdate={isUpdate} /> <SqlAccountFields isUpdate={isUpdate} />
<RequireMfaField />
<div className="mt-6 flex items-center"> <div className="mt-6 flex items-center">
<Button <Button
className="mr-4" className="mr-4"

View File

@@ -39,7 +39,7 @@ const CreateForm = ({
const createPamAccount = useCreatePamAccount(); const createPamAccount = useCreatePamAccount();
const onSubmit = async ( const onSubmit = async (
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials"> formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials" | "requireMfa">
) => { ) => {
const account = await createPamAccount.mutateAsync({ const account = await createPamAccount.mutateAsync({
...formData, ...formData,
@@ -97,7 +97,7 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
const updatePamAccount = useUpdatePamAccount(); const updatePamAccount = useUpdatePamAccount();
const onSubmit = async ( const onSubmit = async (
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials"> formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials" | "requireMfa">
) => { ) => {
const updatedAccount = await updatePamAccount.mutateAsync({ const updatedAccount = await updatePamAccount.mutateAsync({
accountId: account.id, accountId: account.id,

View File

@@ -15,6 +15,7 @@ import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
import { SqlAccountFields } from "./shared/SqlAccountFields"; import { SqlAccountFields } from "./shared/SqlAccountFields";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
import { RequireMfaField } from "./RequireMfaField";
import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields"; import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields";
type Props = { type Props = {
@@ -25,7 +26,8 @@ type Props = {
}; };
const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({ const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({
credentials: BaseSqlAccountSchema credentials: BaseSqlAccountSchema,
requireMfa: z.boolean().nullable().optional()
}); });
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
@@ -77,6 +79,7 @@ export const PostgresAccountForm = ({ account, resourceId, resourceType, onSubmi
<GenericAccountFields /> <GenericAccountFields />
<SqlAccountFields isUpdate={isUpdate} /> <SqlAccountFields isUpdate={isUpdate} />
<RotateAccountFields rotationCredentialsConfigured={rotationCredentialsConfigured} /> <RotateAccountFields rotationCredentialsConfigured={rotationCredentialsConfigured} />
<RequireMfaField />
<div className="mt-6 flex items-center"> <div className="mt-6 flex items-center">
<Button <Button
className="mr-4" className="mr-4"

View File

@@ -0,0 +1,31 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Switch } from "@app/components/v2";
export const RequireMfaField = () => {
const { control } = useFormContext<{
requireMfa?: boolean | null;
}>();
return (
<div className="flex h-9 w-fit items-center gap-3">
<Controller
control={control}
name="requireMfa"
defaultValue={false}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} className="mb-0">
<Switch
className="ml-0 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="require-mfa"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value ?? false}
/>
</FormControl>
)}
/>
<span className="text-sm">Require MFA for Access</span>
</div>
);
};

View File

@@ -6,7 +6,7 @@ import { FormControl, Select, SelectItem, Switch, Tooltip } from "@app/component
export const rotateAccountFieldsSchema = z.object({ export const rotateAccountFieldsSchema = z.object({
rotationEnabled: z.boolean(), rotationEnabled: z.boolean(),
rotationIntervalSeconds: z.number().nullable().optional() rotationIntervalSeconds: z.number()
}); });
export const RotateAccountFields = ({ export const RotateAccountFields = ({
@@ -16,15 +16,15 @@ export const RotateAccountFields = ({
}) => { }) => {
const { control, watch } = useFormContext<{ const { control, watch } = useFormContext<{
rotationEnabled: boolean; rotationEnabled: boolean;
rotationIntervalSeconds?: number | null; rotationIntervalSeconds: number;
}>(); }>();
const rotationEnabled = watch("rotationEnabled"); const rotationEnabled = watch("rotationEnabled");
return ( return (
<Tooltip <Tooltip
isDisabled={rotationCredentialsConfigured}
content="The resource which owns this account does not have rotation credentials configured." content="The resource which owns this account does not have rotation credentials configured."
isDisabled={rotationCredentialsConfigured}
> >
<div <div
className={twMerge( className={twMerge(
@@ -62,7 +62,7 @@ export const RotateAccountFields = ({
className="mb-0" className="mb-0"
> >
<Select <Select
value={(value || 2592000).toString()} value={value.toString()}
onValueChange={(val) => onChange(parseInt(val, 10))} onValueChange={(val) => onChange(parseInt(val, 10))}
className="w-full border border-mineshaft-500 capitalize" className="w-full border border-mineshaft-500 capitalize"
position="popper" position="popper"

View File

@@ -18,6 +18,7 @@ import { SSHAuthMethod } from "@app/hooks/api/pam/types/ssh-resource";
import { SshCaSetupSection } from "../../../components/SshCaSetupSection"; import { SshCaSetupSection } from "../../../components/SshCaSetupSection";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
import { RequireMfaField } from "./RequireMfaField";
type Props = { type Props = {
account?: TSSHAccount; account?: TSSHAccount;
@@ -53,7 +54,8 @@ const formSchema = genericAccountFieldsSchema.extend({
credentials: BaseSshAccountSchema, credentials: BaseSshAccountSchema,
// We don't support rotation for now, just feed a false value to // We don't support rotation for now, just feed a false value to
// make the schema happy // make the schema happy
rotationEnabled: z.boolean().default(false) rotationEnabled: z.boolean().default(false),
requireMfa: z.boolean().nullable().optional()
}); });
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
@@ -225,6 +227,8 @@ export const SshAccountForm = ({ account, resourceId, onSubmit }: Props) => {
: { : {
name: "", name: "",
description: "", description: "",
requireMfa: false,
rotationEnabled: false,
credentials: { credentials: {
authMethod: SSHAuthMethod.Password, authMethod: SSHAuthMethod.Password,
username: "", username: "",
@@ -247,6 +251,7 @@ export const SshAccountForm = ({ account, resourceId, onSubmit }: Props) => {
> >
<GenericAccountFields /> <GenericAccountFields />
<SshAccountFields isUpdate={isUpdate} resourceId={effectiveResourceId} /> <SshAccountFields isUpdate={isUpdate} resourceId={effectiveResourceId} />
<RequireMfaField />
<div className="mt-6 flex items-center"> <div className="mt-6 flex items-center">
<Button <Button
className="mr-4" className="mr-4"

View File

@@ -66,12 +66,9 @@ export const PamAccountRow = ({
type: "info" type: "info"
}); });
const timer = setTimeout(() => setIsIdCopied.off(), 2000); setTimeout(() => setIsIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, },
[isIdCopied] [setIsIdCopied]
); );
return ( return (

View File

@@ -16,7 +16,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router"; import { useNavigate, useSearch } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
@@ -251,13 +250,8 @@ export const PamAccountsTable = ({ projectId }: Props) => {
}); });
if (requiresApproval) { if (requiresApproval) {
createNotification({
text: "This account is protected by an approval policy, you must request access",
type: "info"
});
// Open request access modal with pre-populated path // Open request access modal with pre-populated path
handlePopUpOpen("requestAccount", { accountPath: fullAccountPath }); handlePopUpOpen("requestAccount", { accountPath: fullAccountPath, accountAccessed: true });
return; return;
} }
@@ -439,6 +433,8 @@ export const PamAccountsTable = ({ projectId }: Props) => {
account={account} account={account}
accountPath={account.folderId ? folderPaths[account.folderId] : undefined} accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
onAccess={(e: TPamAccount) => accessAccount(e)} onAccess={(e: TPamAccount) => accessAccount(e)}
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
/> />
))} ))}
</div> </div>
@@ -560,6 +556,7 @@ export const PamAccountsTable = ({ projectId }: Props) => {
isOpen={popUp.requestAccount.isOpen} isOpen={popUp.requestAccount.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("requestAccount", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("requestAccount", isOpen)}
accountPath={popUp.requestAccount.data?.accountPath} accountPath={popUp.requestAccount.data?.accountPath}
accountAccessed={popUp.requestAccount.data?.accountAccessed}
/> />
<PamDeleteAccountModal <PamDeleteAccountModal
isOpen={popUp.deleteAccount.isOpen} isOpen={popUp.deleteAccount.isOpen}

View File

@@ -1,5 +1,6 @@
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { InfoIcon } from "lucide-react";
import ms from "ms"; import ms from "ms";
import { z } from "zod"; import { z } from "zod";
@@ -17,9 +18,11 @@ import {
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies"; import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies";
import { useCreateApprovalRequest } from "@app/hooks/api/approvalRequests/mutations"; import { useCreateApprovalRequest } from "@app/hooks/api/approvalRequests/mutations";
import { UnstableAlert, UnstableAlertDescription, UnstableAlertTitle } from "@app/components/v3";
type Props = { type Props = {
accountPath?: string; accountPath?: string;
accountAccessed?: boolean;
isOpen: boolean; isOpen: boolean;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
}; };
@@ -45,7 +48,7 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
const Content = ({ onOpenChange, accountPath }: Props) => { const Content = ({ onOpenChange, accountPath, accountAccessed }: Props) => {
const { projectId } = useProject(); const { projectId } = useProject();
const { mutateAsync: createApprovalRequest, isPending: isSubmitting } = const { mutateAsync: createApprovalRequest, isPending: isSubmitting } =
useCreateApprovalRequest(); useCreateApprovalRequest();
@@ -94,6 +97,15 @@ const Content = ({ onOpenChange, accountPath }: Props) => {
return ( return (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
{accountAccessed && (
<UnstableAlert variant="info" className="mb-3">
<InfoIcon />
<UnstableAlertTitle>This account is protected by an approval policy</UnstableAlertTitle>
<UnstableAlertDescription>
You must request access by filling out the fields below.
</UnstableAlertDescription>
</UnstableAlert>
)}
<Controller <Controller
name="accountPath" name="accountPath"
control={control} control={control}

View File

@@ -34,7 +34,7 @@ export const GroupDetailsSection = ({ groupMembership }: Props) => {
return ( return (
<UnstableCard className="w-full lg:max-w-[24rem]"> <UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader> <UnstableCardHeader className="border-b">
<UnstableCardTitle>Details</UnstableCardTitle> <UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>Group details</UnstableCardDescription> <UnstableCardDescription>Group details</UnstableCardDescription>
</UnstableCardHeader> </UnstableCardHeader>

View File

@@ -85,7 +85,7 @@ export const ProjectIdentityAuthenticationSection = ({ identity, refetchIdentity
activeLockoutAuthMethods={identity.activeLockoutAuthMethods} activeLockoutAuthMethods={identity.activeLockoutAuthMethods}
/> />
) : ( ) : (
<UnstableEmpty className="rounded-sm border bg-mineshaft-800/50"> <UnstableEmpty className="border">
<UnstableEmptyHeader> <UnstableEmptyHeader>
<UnstableEmptyTitle> <UnstableEmptyTitle>
This machine identity has no auth methods configured This machine identity has no auth methods configured

View File

@@ -51,7 +51,7 @@ export const ProjectIdentityDetailsSection = ({
return ( return (
<> <>
<UnstableCard className="w-full lg:max-w-[24rem]"> <UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader> <UnstableCardHeader className="border-b">
<UnstableCardTitle>Details</UnstableCardTitle> <UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>Machine identity details</UnstableCardDescription> <UnstableCardDescription>Machine identity details</UnstableCardDescription>
{!isOrgIdentity && ( {!isOrgIdentity && (

View File

@@ -40,7 +40,7 @@ export const ProjectMemberDetailsSection = ({ membership }: Props) => {
return ( return (
<UnstableCard className="w-full lg:max-w-[24rem]"> <UnstableCard className="w-full lg:max-w-[24rem]">
<UnstableCardHeader> <UnstableCardHeader className="border-b">
<UnstableCardTitle>Details</UnstableCardTitle> <UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>User membership details</UnstableCardDescription> <UnstableCardDescription>User membership details</UnstableCardDescription>
</UnstableCardHeader> </UnstableCardHeader>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { startRegistration } from "@simplewebauthn/browser";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import QRCode from "qrcode"; import QRCode from "qrcode";
@@ -11,6 +12,8 @@ import {
EmailServiceSetupModal, EmailServiceSetupModal,
FormControl, FormControl,
Input, Input,
Modal,
ModalContent,
Select, Select,
SelectItem SelectItem
} from "@app/components/v2"; } from "@app/components/v2";
@@ -28,6 +31,13 @@ import {
useGetUserTotpRegistration useGetUserTotpRegistration
} from "@app/hooks/api/users/queries"; } from "@app/hooks/api/users/queries";
import { AuthMethod } from "@app/hooks/api/users/types"; import { AuthMethod } from "@app/hooks/api/users/types";
import {
useDeleteWebAuthnCredential,
useGenerateRegistrationOptions,
useGetWebAuthnCredentials,
useUpdateWebAuthnCredential,
useVerifyRegistration
} from "@app/hooks/api/webauthn";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
export const MFASection = () => { export const MFASection = () => {
@@ -46,7 +56,10 @@ export const MFASection = () => {
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"setUpEmail", "setUpEmail",
"deleteTotpConfig", "deleteTotpConfig",
"downloadRecoveryCodes" "downloadRecoveryCodes",
"deleteWebAuthnCredential",
"renameWebAuthnCredential",
"registerPasskey"
] as const); ] as const);
const [shouldShowRecoveryCodes, setShouldShowRecoveryCodes] = useToggle(); const [shouldShowRecoveryCodes, setShouldShowRecoveryCodes] = useToggle();
const { data: totpConfiguration } = useGetUserTotpConfiguration(); const { data: totpConfiguration } = useGetUserTotpConfiguration();
@@ -60,6 +73,20 @@ export const MFASection = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: serverDetails } = useFetchServerStatus(); const { data: serverDetails } = useFetchServerStatus();
// WebAuthn/Passkey hooks
const { data: webAuthnCredentials, isPending: isWebAuthnCredentialsLoading } =
useGetWebAuthnCredentials();
const { mutateAsync: generateRegistrationOptions, isPending: isGeneratingOptions } =
useGenerateRegistrationOptions();
const { mutateAsync: verifyRegistration, isPending: isVerifyingRegistration } =
useVerifyRegistration();
const { mutateAsync: deleteWebAuthnCredential } = useDeleteWebAuthnCredential();
const { mutateAsync: updateWebAuthnCredential } = useUpdateWebAuthnCredential();
const [selectedCredentialId, setSelectedCredentialId] = useState<string>("");
const [credentialName, setCredentialName] = useState<string>("");
const [isRegisteringPasskey, setIsRegisteringPasskey] = useState(false);
// Update form data when user data changes // Update form data when user data changes
useEffect(() => { useEffect(() => {
if (user) { if (user) {
@@ -107,6 +134,114 @@ export const MFASection = () => {
}); });
}; };
const handleRegisterPasskey = async () => {
try {
setIsRegisteringPasskey(true);
// Check if WebAuthn is supported
if (
!window.PublicKeyCredential ||
!window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable
) {
createNotification({
text: "WebAuthn is not supported on this browser",
type: "error"
});
return;
}
// Check if platform authenticator is available
const available =
await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
if (!available) {
createNotification({
text: "No passkey-compatible authenticator found on this device",
type: "error"
});
return;
}
// Generate registration options from server
const options = await generateRegistrationOptions();
const registrationResponse = await startRegistration({ optionsJSON: options });
// Verify registration with server
await verifyRegistration({
registrationResponse,
name: credentialName || "Passkey"
});
createNotification({
text: "Successfully registered passkey",
type: "success"
});
handlePopUpClose("registerPasskey");
setCredentialName("");
} catch (error: any) {
console.error("Failed to register passkey:", error);
// Better error messages
let errorMessage = "Failed to register passkey";
if (error.name === "NotAllowedError") {
errorMessage = "Passkey registration was cancelled or timed out";
} else if (error.name === "InvalidStateError") {
errorMessage = "This passkey has already been registered";
} else if (error.message) {
errorMessage = error.message;
}
createNotification({
text: errorMessage,
type: "error"
});
} finally {
setIsRegisteringPasskey(false);
}
};
const handleDeleteWebAuthnCredential = async () => {
try {
await deleteWebAuthnCredential({ id: selectedCredentialId });
createNotification({
text: "Successfully deleted passkey",
type: "success"
});
handlePopUpClose("deleteWebAuthnCredential");
setSelectedCredentialId("");
} catch (error: any) {
createNotification({
text: error.message || "Failed to delete passkey",
type: "error"
});
}
};
const handleRenameWebAuthnCredential = async () => {
try {
await updateWebAuthnCredential({
id: selectedCredentialId,
name: credentialName
});
createNotification({
text: "Successfully renamed passkey",
type: "success"
});
handlePopUpClose("renameWebAuthnCredential");
setSelectedCredentialId("");
setCredentialName("");
} catch (error: any) {
createNotification({
text: error.message || "Failed to rename passkey",
type: "error"
});
}
};
const handleFormDataChange = async (field: string, value: any) => { const handleFormDataChange = async (field: string, value: any) => {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
@@ -149,6 +284,19 @@ export const MFASection = () => {
return; return;
} }
// If selecting passkey as 2FA method but no passkeys registered
if (
formData.isMfaEnabled &&
formData.selectedMfaMethod === MfaMethod.WEBAUTHN &&
(!webAuthnCredentials || webAuthnCredentials.length === 0)
) {
createNotification({
text: "Please register at least one passkey before selecting it as your two-factor authentication method",
type: "error"
});
return;
}
setIsLoading(true); setIsLoading(true);
// If enabling 2FA with mobile authenticator, verify TOTP first // If enabling 2FA with mobile authenticator, verify TOTP first
@@ -240,9 +388,97 @@ export const MFASection = () => {
if (totpConfiguration?.isVerified) return true; if (totpConfiguration?.isVerified) return true;
return totpCode.trim().length > 0; return totpCode.trim().length > 0;
} }
if (formData.selectedMfaMethod === MfaMethod.WEBAUTHN) return true;
return false; return false;
}; };
const registerPasskeyModal = (
<Modal
isOpen={popUp.registerPasskey?.isOpen || false}
onOpenChange={(isOpen) => {
if (!isOpen) {
handlePopUpClose("registerPasskey");
setCredentialName("");
}
}}
>
<ModalContent title="Register New Passkey">
<div className="space-y-4">
<p className="text-sm text-mineshaft-300">
Give your passkey a name to help you identify it later. After clicking
&quot;Register&quot;, you&apos;ll be prompted to use your device&apos;s biometric
authentication (Touch ID, Face ID, Windows Hello, etc.).
</p>
<FormControl label="Passkey Name">
<Input
value={credentialName}
onChange={(e) => setCredentialName(e.target.value)}
placeholder="e.g., My MacBook Pro"
/>
</FormControl>
<div className="flex gap-2">
<Button
onClick={handleRegisterPasskey}
isLoading={isRegisteringPasskey || isVerifyingRegistration}
disabled={!credentialName.trim()}
colorSchema="primary"
>
Register
</Button>
<Button
variant="outline_bg"
onClick={() => handlePopUpClose("registerPasskey")}
disabled={isRegisteringPasskey || isVerifyingRegistration}
>
Cancel
</Button>
</div>
</div>
</ModalContent>
</Modal>
);
const renamePasskeyModal = (
<Modal
isOpen={popUp.renameWebAuthnCredential?.isOpen || false}
onOpenChange={(isOpen) => {
if (!isOpen) {
handlePopUpClose("renameWebAuthnCredential");
setCredentialName("");
setSelectedCredentialId("");
}
}}
>
<ModalContent title="Rename Passkey">
<div className="space-y-4">
<FormControl label="Passkey Name">
<Input
value={credentialName}
onChange={(e) => setCredentialName(e.target.value)}
placeholder="e.g., My MacBook Pro"
/>
</FormControl>
<div className="flex gap-2">
<Button
onClick={handleRenameWebAuthnCredential}
disabled={!credentialName.trim()}
colorSchema="primary"
>
Save
</Button>
<Button
variant="outline_bg"
onClick={() => handlePopUpClose("renameWebAuthnCredential")}
>
Cancel
</Button>
</div>
</div>
</ModalContent>
</Modal>
);
return ( return (
<> <>
<form <form
@@ -293,6 +529,7 @@ export const MFASection = () => {
> >
<SelectItem value={MfaMethod.EMAIL}>Email</SelectItem> <SelectItem value={MfaMethod.EMAIL}>Email</SelectItem>
<SelectItem value={MfaMethod.TOTP}>Mobile Authenticator</SelectItem> <SelectItem value={MfaMethod.TOTP}>Mobile Authenticator</SelectItem>
<SelectItem value={MfaMethod.WEBAUTHN}>Passkey (WebAuthn)</SelectItem>
</Select> </Select>
</FormControl> </FormControl>
</div> </div>
@@ -411,57 +648,162 @@ export const MFASection = () => {
</Button> </Button>
</div> </div>
)} )}
{user?.isMfaEnabled && totpConfiguration?.isVerified && (
<div className="mt-8 border-t border-mineshaft-600 pt-6">
<h3 className="mb-4 text-lg font-medium text-mineshaft-100">
Mobile Authenticator Management
</h3>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button
colorSchema="secondary"
variant="outline_bg"
onClick={setShouldShowRecoveryCodes.toggle}
>
{shouldShowRecoveryCodes ? "Hide recovery codes" : "Show recovery codes"}
</Button>
<Button
colorSchema="secondary"
variant="outline_bg"
onClick={handleGenerateMoreRecoveryCodes}
>
Generate more codes
</Button>
<Button
colorSchema="danger"
variant="outline_bg"
onClick={() => handlePopUpOpen("deleteTotpConfig")}
>
Remove Authenticator
</Button>
</div>
{shouldShowRecoveryCodes && (
<div className="w-fit rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4 pr-8">
<div className="grid grid-cols-2 gap-x-6 gap-y-2 font-mono text-sm">
{totpConfiguration.recoveryCodes.map((code, index) => (
<div key={code} className="flex items-center text-mineshaft-200">
<span className="w-8 text-right text-mineshaft-400">{index + 1}.</span>
<span className="pl-2">{code}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</div> </div>
)} )}
</form> </form>
{/* Management Sections - Separate from configuration form */}
{user && (
<div className="mt-6 mb-6 space-y-6">
{/* Mobile Authenticator Management - Show if configured, regardless of active method */}
{totpConfiguration?.isVerified && (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<h3 className="mb-4 text-lg font-medium text-mineshaft-100">
Mobile Authenticator Management
</h3>
<p className="mb-4 text-sm text-mineshaft-400">
Manage your mobile authenticator configuration and recovery codes.
</p>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button
colorSchema="secondary"
variant="outline_bg"
onClick={setShouldShowRecoveryCodes.toggle}
>
{shouldShowRecoveryCodes ? "Hide recovery codes" : "Show recovery codes"}
</Button>
<Button
colorSchema="secondary"
variant="outline_bg"
onClick={handleGenerateMoreRecoveryCodes}
>
Generate more codes
</Button>
<Button
colorSchema="danger"
variant="outline_bg"
onClick={() => handlePopUpOpen("deleteTotpConfig")}
>
Remove Authenticator
</Button>
</div>
{shouldShowRecoveryCodes && (
<div className="w-fit rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4 pr-8">
<div className="grid grid-cols-2 gap-x-6 gap-y-2 font-mono text-sm">
{totpConfiguration.recoveryCodes.map((code, index) => (
<div key={code} className="flex items-center text-mineshaft-200">
<span className="w-8 text-right text-mineshaft-400">{index + 1}.</span>
<span className="pl-2">{code}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Passkey Management - Always show */}
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-mineshaft-100">Passkey Management</h3>
<p className="mt-1 text-sm text-mineshaft-400">
Manage your passkeys. Passkeys can be used for two-factor authentication.
</p>
</div>
<Button
colorSchema="primary"
variant="outline_bg"
onClick={() => {
setCredentialName("");
handlePopUpOpen("registerPasskey");
}}
isLoading={isGeneratingOptions}
>
Add Passkey
</Button>
</div>
{(() => {
if (isWebAuthnCredentialsLoading) {
return <ContentLoader />;
}
if (webAuthnCredentials && webAuthnCredentials.length > 0) {
return (
<div className="space-y-3">
{webAuthnCredentials.map((credential) => (
<div
key={credential.id}
className="flex items-center justify-between rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-mineshaft-100">
{credential.name || "Unnamed Passkey"}
</span>
{credential.transports && credential.transports.length > 0 && (
<span className="text-xs text-mineshaft-400">
({credential.transports.join(", ")})
</span>
)}
</div>
<div className="mt-1 text-xs text-mineshaft-400">
Added {new Date(credential.createdAt).toLocaleDateString()}
{credential.lastUsedAt && (
<>
{" "}
· Last used {new Date(credential.lastUsedAt).toLocaleDateString()}
</>
)}
</div>
</div>
<div className="flex gap-2">
<Button
size="xs"
colorSchema="secondary"
variant="outline_bg"
onClick={() => {
setSelectedCredentialId(credential.id);
setCredentialName(credential.name || "");
handlePopUpOpen("renameWebAuthnCredential");
}}
>
Rename
</Button>
<Button
size="xs"
colorSchema="danger"
variant="outline_bg"
onClick={() => {
setSelectedCredentialId(credential.id);
handlePopUpOpen("deleteWebAuthnCredential");
}}
>
Remove
</Button>
</div>
</div>
))}
</div>
);
}
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-6 text-center">
<p className="text-sm text-mineshaft-300">
No passkeys registered yet. Add a passkey to use it for two-factor
authentication.
</p>
</div>
);
})()}
</div>
</div>
)}
<EmailServiceSetupModal <EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen} isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
@@ -481,6 +823,19 @@ export const MFASection = () => {
recoveryCodes={totpRegistration?.recoveryCodes || []} recoveryCodes={totpRegistration?.recoveryCodes || []}
onDownloadComplete={() => handlePopUpClose("downloadRecoveryCodes")} onDownloadComplete={() => handlePopUpClose("downloadRecoveryCodes")}
/> />
{registerPasskeyModal}
{renamePasskeyModal}
{/* Delete Passkey Modal */}
<DeleteActionModal
isOpen={popUp.deleteWebAuthnCredential?.isOpen || false}
title="Remove passkey?"
subTitle="This action is irreversible. You'll need to register this passkey again if you want to use it in the future."
onChange={(isOpen) => handlePopUpToggle("deleteWebAuthnCredential", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleDeleteWebAuthnCredential}
/>
</> </>
); );
}; };

View File

@@ -38,6 +38,7 @@ import { Route as authAdminLoginPageRouteImport } from './pages/auth/AdminLoginP
import { Route as adminSignUpPageRouteImport } from './pages/admin/SignUpPage/route' import { Route as adminSignUpPageRouteImport } from './pages/admin/SignUpPage/route'
import { Route as organizationNoOrgPageRouteImport } from './pages/organization/NoOrgPage/route' import { Route as organizationNoOrgPageRouteImport } from './pages/organization/NoOrgPage/route'
import { Route as organizationMcpEndpointFinalizePageRouteImport } from './pages/organization/McpEndpointFinalizePage/route' import { Route as organizationMcpEndpointFinalizePageRouteImport } from './pages/organization/McpEndpointFinalizePage/route'
import { Route as MfaSessionPageRouteImport } from './pages/MfaSessionPage/route'
import { Route as authSignUpPageRouteImport } from './pages/auth/SignUpPage/route' import { Route as authSignUpPageRouteImport } from './pages/auth/SignUpPage/route'
import { Route as authLoginPageRouteImport } from './pages/auth/LoginPage/route' import { Route as authLoginPageRouteImport } from './pages/auth/LoginPage/route'
import { Route as redirectsProjectRedirectImport } from './pages/redirects/project-redirect' import { Route as redirectsProjectRedirectImport } from './pages/redirects/project-redirect'
@@ -549,6 +550,12 @@ const organizationMcpEndpointFinalizePageRouteRoute =
getParentRoute: () => middlewaresAuthenticateRoute, getParentRoute: () => middlewaresAuthenticateRoute,
} as any) } as any)
const MfaSessionPageRouteRoute = MfaSessionPageRouteImport.update({
id: '/mfa-session/$mfaSessionId',
path: '/mfa-session/$mfaSessionId',
getParentRoute: () => middlewaresAuthenticateRoute,
} as any)
const authSignUpPageRouteRoute = authSignUpPageRouteImport.update({ const authSignUpPageRouteRoute = authSignUpPageRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@@ -2478,6 +2485,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof authSignUpPageRouteImport preLoaderRoute: typeof authSignUpPageRouteImport
parentRoute: typeof RestrictLoginSignupSignupImport parentRoute: typeof RestrictLoginSignupSignupImport
} }
'/_authenticate/mfa-session/$mfaSessionId': {
id: '/_authenticate/mfa-session/$mfaSessionId'
path: '/mfa-session/$mfaSessionId'
fullPath: '/mfa-session/$mfaSessionId'
preLoaderRoute: typeof MfaSessionPageRouteImport
parentRoute: typeof middlewaresAuthenticateImport
}
'/_authenticate/organization/mcp-endpoint-finalize': { '/_authenticate/organization/mcp-endpoint-finalize': {
id: '/_authenticate/organization/mcp-endpoint-finalize' id: '/_authenticate/organization/mcp-endpoint-finalize'
path: '/organization/mcp-endpoint-finalize' path: '/organization/mcp-endpoint-finalize'
@@ -5268,6 +5282,7 @@ interface middlewaresAuthenticateRouteChildren {
authPasswordSetupPageRouteRoute: typeof authPasswordSetupPageRouteRoute authPasswordSetupPageRouteRoute: typeof authPasswordSetupPageRouteRoute
middlewaresInjectOrgDetailsRoute: typeof middlewaresInjectOrgDetailsRouteWithChildren middlewaresInjectOrgDetailsRoute: typeof middlewaresInjectOrgDetailsRouteWithChildren
AuthenticatePersonalSettingsRoute: typeof AuthenticatePersonalSettingsRouteWithChildren AuthenticatePersonalSettingsRoute: typeof AuthenticatePersonalSettingsRouteWithChildren
MfaSessionPageRouteRoute: typeof MfaSessionPageRouteRoute
organizationMcpEndpointFinalizePageRouteRoute: typeof organizationMcpEndpointFinalizePageRouteRoute organizationMcpEndpointFinalizePageRouteRoute: typeof organizationMcpEndpointFinalizePageRouteRoute
organizationNoOrgPageRouteRoute: typeof organizationNoOrgPageRouteRoute organizationNoOrgPageRouteRoute: typeof organizationNoOrgPageRouteRoute
} }
@@ -5279,6 +5294,7 @@ const middlewaresAuthenticateRouteChildren: middlewaresAuthenticateRouteChildren
middlewaresInjectOrgDetailsRouteWithChildren, middlewaresInjectOrgDetailsRouteWithChildren,
AuthenticatePersonalSettingsRoute: AuthenticatePersonalSettingsRoute:
AuthenticatePersonalSettingsRouteWithChildren, AuthenticatePersonalSettingsRouteWithChildren,
MfaSessionPageRouteRoute: MfaSessionPageRouteRoute,
organizationMcpEndpointFinalizePageRouteRoute: organizationMcpEndpointFinalizePageRouteRoute:
organizationMcpEndpointFinalizePageRouteRoute, organizationMcpEndpointFinalizePageRouteRoute,
organizationNoOrgPageRouteRoute: organizationNoOrgPageRouteRoute, organizationNoOrgPageRouteRoute: organizationNoOrgPageRouteRoute,
@@ -5376,6 +5392,7 @@ export interface FileRoutesByFullPath {
'/signup': typeof RestrictLoginSignupSignupRouteWithChildren '/signup': typeof RestrictLoginSignupSignupRouteWithChildren
'/login/': typeof authLoginPageRouteRoute '/login/': typeof authLoginPageRouteRoute
'/signup/': typeof authSignUpPageRouteRoute '/signup/': typeof authSignUpPageRouteRoute
'/mfa-session/$mfaSessionId': typeof MfaSessionPageRouteRoute
'/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute '/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
'/organizations/none': typeof organizationNoOrgPageRouteRoute '/organizations/none': typeof organizationNoOrgPageRouteRoute
'/admin/signup': typeof adminSignUpPageRouteRoute '/admin/signup': typeof adminSignUpPageRouteRoute
@@ -5631,6 +5648,7 @@ export interface FileRoutesByTo {
'/personal-settings': typeof userPersonalSettingsPageRouteRoute '/personal-settings': typeof userPersonalSettingsPageRouteRoute
'/login': typeof authLoginPageRouteRoute '/login': typeof authLoginPageRouteRoute
'/signup': typeof authSignUpPageRouteRoute '/signup': typeof authSignUpPageRouteRoute
'/mfa-session/$mfaSessionId': typeof MfaSessionPageRouteRoute
'/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute '/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
'/organizations/none': typeof organizationNoOrgPageRouteRoute '/organizations/none': typeof organizationNoOrgPageRouteRoute
'/admin/signup': typeof adminSignUpPageRouteRoute '/admin/signup': typeof adminSignUpPageRouteRoute
@@ -5878,6 +5896,7 @@ export interface FileRoutesById {
'/_restrict-login-signup/signup': typeof RestrictLoginSignupSignupRouteWithChildren '/_restrict-login-signup/signup': typeof RestrictLoginSignupSignupRouteWithChildren
'/_restrict-login-signup/login/': typeof authLoginPageRouteRoute '/_restrict-login-signup/login/': typeof authLoginPageRouteRoute
'/_restrict-login-signup/signup/': typeof authSignUpPageRouteRoute '/_restrict-login-signup/signup/': typeof authSignUpPageRouteRoute
'/_authenticate/mfa-session/$mfaSessionId': typeof MfaSessionPageRouteRoute
'/_authenticate/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute '/_authenticate/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
'/_authenticate/organizations/none': typeof organizationNoOrgPageRouteRoute '/_authenticate/organizations/none': typeof organizationNoOrgPageRouteRoute
'/_restrict-login-signup/admin/signup': typeof adminSignUpPageRouteRoute '/_restrict-login-signup/admin/signup': typeof adminSignUpPageRouteRoute
@@ -6147,6 +6166,7 @@ export interface FileRouteTypes {
| '/signup' | '/signup'
| '/login/' | '/login/'
| '/signup/' | '/signup/'
| '/mfa-session/$mfaSessionId'
| '/organization/mcp-endpoint-finalize' | '/organization/mcp-endpoint-finalize'
| '/organizations/none' | '/organizations/none'
| '/admin/signup' | '/admin/signup'
@@ -6401,6 +6421,7 @@ export interface FileRouteTypes {
| '/personal-settings' | '/personal-settings'
| '/login' | '/login'
| '/signup' | '/signup'
| '/mfa-session/$mfaSessionId'
| '/organization/mcp-endpoint-finalize' | '/organization/mcp-endpoint-finalize'
| '/organizations/none' | '/organizations/none'
| '/admin/signup' | '/admin/signup'
@@ -6646,6 +6667,7 @@ export interface FileRouteTypes {
| '/_restrict-login-signup/signup' | '/_restrict-login-signup/signup'
| '/_restrict-login-signup/login/' | '/_restrict-login-signup/login/'
| '/_restrict-login-signup/signup/' | '/_restrict-login-signup/signup/'
| '/_authenticate/mfa-session/$mfaSessionId'
| '/_authenticate/organization/mcp-endpoint-finalize' | '/_authenticate/organization/mcp-endpoint-finalize'
| '/_authenticate/organizations/none' | '/_authenticate/organizations/none'
| '/_restrict-login-signup/admin/signup' | '/_restrict-login-signup/admin/signup'
@@ -6960,6 +6982,7 @@ export const routeTree = rootRoute
"/_authenticate/password-setup", "/_authenticate/password-setup",
"/_authenticate/_inject-org-details", "/_authenticate/_inject-org-details",
"/_authenticate/personal-settings", "/_authenticate/personal-settings",
"/_authenticate/mfa-session/$mfaSessionId",
"/_authenticate/organization/mcp-endpoint-finalize", "/_authenticate/organization/mcp-endpoint-finalize",
"/_authenticate/organizations/none" "/_authenticate/organizations/none"
] ]
@@ -7047,6 +7070,10 @@ export const routeTree = rootRoute
"filePath": "auth/SignUpPage/route.tsx", "filePath": "auth/SignUpPage/route.tsx",
"parent": "/_restrict-login-signup/signup" "parent": "/_restrict-login-signup/signup"
}, },
"/_authenticate/mfa-session/$mfaSessionId": {
"filePath": "MfaSessionPage/route.tsx",
"parent": "/_authenticate"
},
"/_authenticate/organization/mcp-endpoint-finalize": { "/_authenticate/organization/mcp-endpoint-finalize": {
"filePath": "organization/McpEndpointFinalizePage/route.tsx", "filePath": "organization/McpEndpointFinalizePage/route.tsx",
"parent": "/_authenticate" "parent": "/_authenticate"

View File

@@ -443,6 +443,7 @@ export const routes = rootRoute("root.tsx", [
]), ]),
middleware("authenticate.tsx", [ middleware("authenticate.tsx", [
route("/password-setup", "auth/PasswordSetupPage/route.tsx"), route("/password-setup", "auth/PasswordSetupPage/route.tsx"),
route("/mfa-session/$mfaSessionId", "MfaSessionPage/route.tsx"),
route("/personal-settings", [ route("/personal-settings", [
layout("user/layout.tsx", [index("user/PersonalSettingsPage/route.tsx")]) layout("user/layout.tsx", [index("user/PersonalSettingsPage/route.tsx")])
]), ]),