mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-08 23:18:05 -05:00
Merge branch 'main' into ENG-4013
This commit is contained in:
4
.github/workflows/run-backend-bdd-tests.yml
vendored
4
.github/workflows/run-backend-bdd-tests.yml
vendored
@@ -41,6 +41,10 @@ jobs:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
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
|
||||
run: npm install
|
||||
working-directory: backend
|
||||
|
||||
@@ -140,6 +140,33 @@ RUN apt-get update && apt-get install -y \
|
||||
openssh-client \
|
||||
&& 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
|
||||
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
|
||||
|
||||
|
||||
@@ -137,10 +137,37 @@ RUN apt-get update && apt-get install -y \
|
||||
unixodbc-dev \
|
||||
libc-dev \
|
||||
freetds-dev \
|
||||
wget \
|
||||
wget \
|
||||
openssh-client \
|
||||
&& 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
|
||||
RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \
|
||||
&& apt-get update && apt-get install -y infisical=0.43.14 \
|
||||
|
||||
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
@@ -1 +1,3 @@
|
||||
dist
|
||||
|
||||
/wallet
|
||||
|
||||
@@ -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
|
||||
|
||||
# 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
|
||||
|
||||
COPY --from=build /app .
|
||||
|
||||
@@ -21,7 +21,18 @@ RUN apt-get update && apt-get install -y \
|
||||
openssh-client \
|
||||
openssl \
|
||||
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)
|
||||
RUN apt-get install -y \
|
||||
@@ -49,6 +60,22 @@ RUN rm -fr ${SOFTHSM2_SOURCES}
|
||||
# Install pkcs11-tool
|
||||
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
|
||||
|
||||
# Install Infisical CLI
|
||||
|
||||
@@ -22,7 +22,17 @@ RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
pkg-config \
|
||||
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)
|
||||
RUN apt-get install -y \
|
||||
@@ -50,6 +60,22 @@ RUN rm -fr ${SOFTHSM2_SOURCES}
|
||||
# Install pkcs11-tool
|
||||
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
|
||||
RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \
|
||||
&& tar -xf openssl-3.1.2.tar.gz \
|
||||
|
||||
358
backend/package-lock.json
generated
358
backend/package-lock.json
generated
@@ -56,6 +56,7 @@
|
||||
"@peculiar/x509": "^1.12.1",
|
||||
"@react-email/components": "^1.0.1",
|
||||
"@serdnam/pino-cloudwatch-transport": "^1.0.4",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@sindresorhus/slugify": "1.1.0",
|
||||
"@slack/oauth": "^3.0.2",
|
||||
"@slack/web-api": "^7.8.0",
|
||||
@@ -157,6 +158,7 @@
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/oracledb": "^6.10.0",
|
||||
"@types/passport-google-oauth20": "^2.0.14",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/picomatch": "^2.3.3",
|
||||
@@ -662,7 +664,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.637.0.tgz",
|
||||
"integrity": "sha512-xUi7x4qDubtA8QREtlblPuAcn91GS/09YVEY/RwU7xCY0aqGuFwgszAANlha4OUIqva8oVj2WO4gJuG+iaSnhw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-browser": "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",
|
||||
"integrity": "sha512-ZPZ7Y/r/w3nx/xpPzGSqSQsB090Xk5aZZOH+WBhTDn/pBEuim09BYXCLzvvxb7R7NnuoQdrTJiwimdJAhHl7ZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-browser": "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",
|
||||
"integrity": "sha512-xKuo4HksZ+F8m9DOfx/ZuWNhaPuqZFPwwy0xqcBT6sWH7OAuBjv/fnpOTzyQhpVTWddlf+ECtMAMrxjxuOExGQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-browser": "5.2.0",
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
@@ -2663,7 +2662,6 @@
|
||||
"version": "3.632.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.632.0.tgz",
|
||||
"integrity": "sha512-Oh1fIWaoZluihOCb/zDEpRTi+6an82fgJz7fyRBugyLhEtDjmvpCQ3oKjzaOhoN+4EvXAm1ZS/ZgpvXBlIRTgw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-browser": "5.2.0",
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
@@ -2740,7 +2738,6 @@
|
||||
"version": "3.632.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.632.0.tgz",
|
||||
"integrity": "sha512-Ss5cBH09icpTvT+jtGGuQlRdwtO7RyE9BF4ZV/CEPATdd9whtJt4Qxdya8BUnkWR7h5HHTrQHqai3YVYjku41A==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-browser": "5.2.0",
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
@@ -5178,7 +5175,6 @@
|
||||
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
@@ -7210,7 +7206,6 @@
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -7232,7 +7227,6 @@
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -8560,6 +8554,12 @@
|
||||
"resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.1.0.tgz",
|
||||
"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": {
|
||||
"version": "0.11.13",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
|
||||
@@ -9213,9 +9213,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@infisical/quic/node_modules/@infisical/quic-linux-arm": {
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.2.0",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"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",
|
||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -10665,7 +10669,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz",
|
||||
"integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^4.0.0",
|
||||
"@octokit/graphql": "^7.1.0",
|
||||
@@ -11109,7 +11112,6 @@
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -11428,147 +11430,160 @@
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz",
|
||||
"integrity": "sha512-Wtk9R7yQxGaIaawHorWKP2OOOm/RZzamOmSWwaqGphIuU6TcKYih0slL6asZlSSZtVoYTrBfrddSOD/jTu9vuQ==",
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz",
|
||||
"integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"@peculiar/asn1-x509-attr": "^2.3.8",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.6.2"
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.3.8.tgz",
|
||||
"integrity": "sha512-ZmAaP2hfzgIGdMLcot8gHTykzoI+X/S53x1xoGbTmratETIaAbSWMiPGvZmXRA0SNEIydpMkzYtq4fQBxN1u1w==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz",
|
||||
"integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.6.2"
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.8.tgz",
|
||||
"integrity": "sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz",
|
||||
"integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.6.2"
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.3.8.tgz",
|
||||
"integrity": "sha512-XhdnCVznMmSmgy68B9pVxiZ1XkKoE1BjO4Hv+eUGiY1pM14msLsFZ3N7K46SoITIVZLq92kKkXpGiTfRjlNLyg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz",
|
||||
"integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.3.8",
|
||||
"@peculiar/asn1-pkcs8": "^2.3.8",
|
||||
"@peculiar/asn1-rsa": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.6.2"
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.3.8.tgz",
|
||||
"integrity": "sha512-rL8k2x59v8lZiwLRqdMMmOJ30GHt6yuHISFIuuWivWjAJjnxzZBVzMTQ72sknX5MeTSSvGwPmEFk2/N8+UztFQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz",
|
||||
"integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.6.2"
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.3.8.tgz",
|
||||
"integrity": "sha512-+nONq5tcK7vm3qdY7ZKoSQGQjhJYMJbwJGbXLFOhmqsFIxEWyQPHyV99+wshOjpOjg0wUSSkEEzX2hx5P6EKeQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz",
|
||||
"integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.3.8",
|
||||
"@peculiar/asn1-pfx": "^2.3.8",
|
||||
"@peculiar/asn1-pkcs8": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"@peculiar/asn1-x509-attr": "^2.3.8",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.6.2"
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-pfx": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.6.0",
|
||||
"@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-rsa": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.8.tgz",
|
||||
"integrity": "sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz",
|
||||
"integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.6.2"
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz",
|
||||
"integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
|
||||
"integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asn1js": "^3.0.5",
|
||||
"pvtsutils": "^1.3.5",
|
||||
"tslib": "^2.6.2"
|
||||
"asn1js": "^3.0.6",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.8.tgz",
|
||||
"integrity": "sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz",
|
||||
"integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"asn1js": "^3.0.5",
|
||||
"ipaddr.js": "^2.1.0",
|
||||
"pvtsutils": "^1.3.5",
|
||||
"tslib": "^2.6.2"
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.3.8.tgz",
|
||||
"integrity": "sha512-4Z8mSN95MOuX04Aku9BUyMdsMKtVQUqWnr627IheiWnwFoheUhX3R4Y2zh23M7m80r4/WG8MOAckRKc77IRv6g==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz",
|
||||
"integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "1.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.12.1.tgz",
|
||||
"integrity": "sha512-2T9t2viNP9m20mky50igPTpn2ByhHl5NlT6wW4Tp4BejQaQ5XDNZgfsabYwYysLXhChABlgtTCpp2gM3JBZRKA==",
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.0.tgz",
|
||||
"integrity": "sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.3.8",
|
||||
"@peculiar/asn1-csr": "^2.3.8",
|
||||
"@peculiar/asn1-ecc": "^2.3.8",
|
||||
"@peculiar/asn1-pkcs9": "^2.3.8",
|
||||
"@peculiar/asn1-rsa": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"pvtsutils": "^1.3.5",
|
||||
"@peculiar/asn1-cms": "^2.5.0",
|
||||
"@peculiar/asn1-csr": "^2.5.0",
|
||||
"@peculiar/asn1-ecc": "^2.5.0",
|
||||
"@peculiar/asn1-pkcs9": "^2.5.0",
|
||||
"@peculiar/asn1-rsa": "^2.5.0",
|
||||
"@peculiar/asn1-schema": "^2.5.0",
|
||||
"@peculiar/asn1-x509": "^2.5.0",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"tslib": "^2.6.2",
|
||||
"tsyringe": "^4.8.0"
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@phc/format": {
|
||||
@@ -11750,7 +11765,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.2.0.tgz",
|
||||
"integrity": "sha512-9GCWmVmKUAoRfloboCd+RKm6X17xn7eGL7HnpAZUnjBXBilWCxsKnLMTC/ixSHDKS/A/057M1Tx6ZUXd89sVBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"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",
|
||||
"integrity": "sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -11773,7 +11786,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.0.tgz",
|
||||
"integrity": "sha512-eIrPW9PIFgDopQU0e/OPpwCW2QWQDtNZDSsiN4sJO8KdMnWWnXJicnRfzrit5rHwFo+Y98i+w/Y5ScnBAFr1dQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prismjs": "^1.30.0"
|
||||
},
|
||||
@@ -11789,7 +11801,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.5.tgz",
|
||||
"integrity": "sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -11865,7 +11876,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.15.tgz",
|
||||
"integrity": "sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -11899,7 +11909,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.15.tgz",
|
||||
"integrity": "sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -11912,7 +11921,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.11.tgz",
|
||||
"integrity": "sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -11937,7 +11945,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.11.tgz",
|
||||
"integrity": "sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -11950,7 +11957,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.12.tgz",
|
||||
"integrity": "sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -11978,7 +11984,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.13.tgz",
|
||||
"integrity": "sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -12083,7 +12088,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.5.tgz",
|
||||
"integrity": "sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -12513,6 +12517,25 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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",
|
||||
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -14129,6 +14151,16 @@
|
||||
"@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": {
|
||||
"version": "1.0.17",
|
||||
"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",
|
||||
"integrity": "sha512-bYerPDF/H5v6V76MdMYhjwmwgMA+jlPVqjSDq2cRqMi8bP5sR3Z+RLOiOMad3nsnmDVmn2gAFCyNgh/dIrfP/w==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.20.0",
|
||||
"@typescript-eslint/types": "6.20.0",
|
||||
@@ -15216,7 +15247,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -15356,22 +15386,6 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "6.0.1",
|
||||
"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",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
@@ -15751,13 +15764,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz",
|
||||
"integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==",
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz",
|
||||
"integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.2",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.3",
|
||||
"tslib": "^2.4.0"
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -15946,7 +15960,6 @@
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
@@ -16548,7 +16561,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
@@ -18103,7 +18115,6 @@
|
||||
"version": "16.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz",
|
||||
"integrity": "sha512-CjA3y+Dr3FyFDOAMnxZEGtnW9KBR2M0JvvUtXNW+dYJL5ROWxP9DUHCwgFqpMk0OXCc0ljhaNTr2w/kutYIcHQ==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -18489,7 +18500,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -18572,7 +18582,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz",
|
||||
"integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@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",
|
||||
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"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",
|
||||
"integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"array-includes": "^3.1.7",
|
||||
"array.prototype.findlastindex": "^1.2.3",
|
||||
@@ -19181,6 +19188,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -19242,6 +19250,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz",
|
||||
"integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.7",
|
||||
@@ -19259,12 +19268,14 @@
|
||||
"node_modules/express-session/node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"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": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -19272,7 +19283,8 @@
|
||||
"node_modules/express-session/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"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": {
|
||||
"version": "0.7.1",
|
||||
@@ -25929,6 +25941,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
|
||||
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -26628,7 +26641,6 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz",
|
||||
"integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -26971,7 +26983,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -27087,7 +27098,6 @@
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
|
||||
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -27459,11 +27469,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz",
|
||||
"integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==",
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.1"
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
@@ -27551,6 +27562,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -27646,7 +27658,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -27656,7 +27667,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@@ -29481,7 +29491,6 @@
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz",
|
||||
"integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ip-address": "^9.0.5",
|
||||
"smart-buffer": "^4.2.0"
|
||||
@@ -30980,7 +30989,6 @@
|
||||
"integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -30996,9 +31004,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz",
|
||||
"integrity": "sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==",
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
@@ -31009,7 +31018,8 @@
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
@@ -31158,7 +31168,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz",
|
||||
"integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -31199,6 +31208,7 @@
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"random-bytes": "~1.0.0"
|
||||
},
|
||||
@@ -31786,7 +31796,6 @@
|
||||
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -32468,7 +32477,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz",
|
||||
"integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/oracledb": "^6.10.0",
|
||||
"@types/passport-google-oauth20": "^2.0.14",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/picomatch": "^2.3.3",
|
||||
@@ -187,6 +188,7 @@
|
||||
"@peculiar/x509": "^1.12.1",
|
||||
"@react-email/components": "^1.0.1",
|
||||
"@serdnam/pino-cloudwatch-transport": "^1.0.4",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@sindresorhus/slugify": "1.1.0",
|
||||
"@slack/oauth": "^3.0.2",
|
||||
"@slack/web-api": "^7.8.0",
|
||||
|
||||
4
backend/src/@types/fastify.d.ts
vendored
4
backend/src/@types/fastify.d.ts
vendored
@@ -102,6 +102,7 @@ import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/i
|
||||
import { TMembershipGroupServiceFactory } from "@app/services/membership-group/membership-group-service";
|
||||
import { TMembershipIdentityServiceFactory } from "@app/services/membership-identity/membership-identity-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 { TNotificationServiceFactory } from "@app/services/notification/notification-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 { TUserServiceFactory } from "@app/services/user/user-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 { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service";
|
||||
|
||||
@@ -329,6 +331,7 @@ declare module "fastify" {
|
||||
externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory;
|
||||
projectTemplate: TProjectTemplateServiceFactory;
|
||||
totp: TTotpServiceFactory;
|
||||
webAuthn: TWebAuthnServiceFactory;
|
||||
appConnection: TAppConnectionServiceFactory;
|
||||
secretSync: TSecretSyncServiceFactory;
|
||||
kmip: TKmipServiceFactory;
|
||||
@@ -355,6 +358,7 @@ declare module "fastify" {
|
||||
pamResource: TPamResourceServiceFactory;
|
||||
pamAccount: TPamAccountServiceFactory;
|
||||
pamSession: TPamSessionServiceFactory;
|
||||
mfaSession: TMfaSessionServiceFactory;
|
||||
upgradePath: TUpgradePathService;
|
||||
|
||||
membershipUser: TMembershipUserServiceFactory;
|
||||
|
||||
8
backend/src/@types/knex.d.ts
vendored
8
backend/src/@types/knex.d.ts
vendored
@@ -614,6 +614,9 @@ import {
|
||||
TVaultExternalMigrationConfigs,
|
||||
TVaultExternalMigrationConfigsInsert,
|
||||
TVaultExternalMigrationConfigsUpdate,
|
||||
TWebauthnCredentials,
|
||||
TWebauthnCredentialsInsert,
|
||||
TWebauthnCredentialsUpdate,
|
||||
TWebhooks,
|
||||
TWebhooksInsert,
|
||||
TWebhooksUpdate,
|
||||
@@ -1528,6 +1531,11 @@ declare module "knex/types/tables" {
|
||||
TVaultExternalMigrationConfigsInsert,
|
||||
TVaultExternalMigrationConfigsUpdate
|
||||
>;
|
||||
[TableName.WebAuthnCredential]: KnexOriginal.CompositeTableType<
|
||||
TWebauthnCredentials,
|
||||
TWebauthnCredentialsInsert,
|
||||
TWebauthnCredentialsUpdate
|
||||
>;
|
||||
[TableName.AiMcpServer]: KnexOriginal.CompositeTableType<TAiMcpServers, TAiMcpServersInsert, TAiMcpServersUpdate>;
|
||||
[TableName.AiMcpServerTool]: KnexOriginal.CompositeTableType<
|
||||
TAiMcpServerTools,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -209,5 +209,6 @@ export * from "./user-encryption-keys";
|
||||
export * from "./user-group-membership";
|
||||
export * from "./users";
|
||||
export * from "./vault-external-migration-configs";
|
||||
export * from "./webauthn-credentials";
|
||||
export * from "./webhooks";
|
||||
export * from "./workflow-integrations";
|
||||
|
||||
@@ -160,6 +160,7 @@ export enum TableName {
|
||||
InternalKms = "internal_kms",
|
||||
InternalKmsKeyVersion = "internal_kms_key_version",
|
||||
TotpConfig = "totp_configs",
|
||||
WebAuthnCredential = "webauthn_credentials",
|
||||
// @depreciated
|
||||
KmsKeyVersion = "kms_key_versions",
|
||||
WorkflowIntegrations = "workflow_integrations",
|
||||
|
||||
@@ -23,7 +23,8 @@ export const PamAccountsSchema = z.object({
|
||||
rotationIntervalSeconds: z.number().nullable().optional(),
|
||||
lastRotatedAt: z.date().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>;
|
||||
|
||||
25
backend/src/db/schemas/webauthn-credentials.ts
Normal file
25
backend/src/db/schemas/webauthn-credentials.ts
Normal 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>>;
|
||||
@@ -24,6 +24,7 @@ export const registerPamAccountEndpoints = <C extends TPamAccount>({
|
||||
description?: C["description"];
|
||||
rotationEnabled?: C["rotationEnabled"];
|
||||
rotationIntervalSeconds?: C["rotationIntervalSeconds"];
|
||||
requireMfa?: C["requireMfa"];
|
||||
}>;
|
||||
updateAccountSchema: z.ZodType<{
|
||||
credentials?: C["credentials"];
|
||||
@@ -31,6 +32,7 @@ export const registerPamAccountEndpoints = <C extends TPamAccount>({
|
||||
description?: C["description"];
|
||||
rotationEnabled?: C["rotationEnabled"];
|
||||
rotationIntervalSeconds?: C["rotationIntervalSeconds"];
|
||||
requireMfa?: C["requireMfa"];
|
||||
}>;
|
||||
accountResponseSchema: z.ZodTypeAny;
|
||||
}) => {
|
||||
@@ -66,7 +68,8 @@ export const registerPamAccountEndpoints = <C extends TPamAccount>({
|
||||
name: req.body.name,
|
||||
description: req.body.description,
|
||||
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,
|
||||
description: req.body.description,
|
||||
rotationEnabled: req.body.rotationEnabled,
|
||||
rotationIntervalSeconds: req.body.rotationIntervalSeconds
|
||||
rotationIntervalSeconds: req.body.rotationIntervalSeconds,
|
||||
requireMfa: req.body.requireMfa
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -115,6 +115,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
body: z.object({
|
||||
accountPath: z.string().trim(),
|
||||
projectId: z.string().uuid(),
|
||||
mfaSessionId: z.string().optional(),
|
||||
duration: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -163,7 +164,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
actorUserAgent: req.auditLogInfo.userAgent ?? "",
|
||||
accountPath: req.body.accountPath,
|
||||
projectId: req.body.projectId,
|
||||
duration: req.body.duration
|
||||
duration: req.body.duration,
|
||||
mfaSessionId: req.body.mfaSessionId
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
@@ -4199,6 +4199,7 @@ interface PamAccountCreateEvent {
|
||||
description?: string | null;
|
||||
rotationEnabled: boolean;
|
||||
rotationIntervalSeconds?: number | null;
|
||||
requireMfa?: boolean | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4212,6 +4213,7 @@ interface PamAccountUpdateEvent {
|
||||
description?: string | null;
|
||||
rotationEnabled?: boolean;
|
||||
rotationIntervalSeconds?: number | null;
|
||||
requireMfa?: boolean | null;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -38,11 +38,16 @@ import { TApprovalPolicyDALFactory } from "@app/services/approval-policy/approva
|
||||
import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums";
|
||||
import { APPROVAL_POLICY_FACTORY_MAP } from "@app/services/approval-policy/approval-policy-factory";
|
||||
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 { 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 { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
import { TUserDALFactory } from "@app/services/user/user-dal";
|
||||
|
||||
import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types";
|
||||
@@ -68,7 +73,9 @@ type TPamAccountServiceFactoryDep = {
|
||||
pamSessionDAL: TPamSessionDALFactory;
|
||||
pamAccountDAL: TPamAccountDALFactory;
|
||||
pamFolderDAL: TPamFolderDALFactory;
|
||||
mfaSessionService: TMfaSessionServiceFactory;
|
||||
projectDAL: TProjectDALFactory;
|
||||
orgDAL: TOrgDALFactory;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
@@ -78,10 +85,13 @@ type TPamAccountServiceFactoryDep = {
|
||||
>;
|
||||
userDAL: TUserDALFactory;
|
||||
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
|
||||
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
|
||||
smtpService: Pick<TSmtpService, "sendMail">;
|
||||
approvalPolicyDAL: TApprovalPolicyDALFactory;
|
||||
approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory;
|
||||
pamSessionExpirationService: Pick<TPamSessionExpirationServiceFactory, "scheduleSessionExpiration">;
|
||||
};
|
||||
|
||||
export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>;
|
||||
|
||||
const ROTATION_CONCURRENCY_LIMIT = 10;
|
||||
@@ -90,8 +100,10 @@ export const pamAccountServiceFactory = ({
|
||||
pamResourceDAL,
|
||||
pamSessionDAL,
|
||||
pamAccountDAL,
|
||||
mfaSessionService,
|
||||
pamFolderDAL,
|
||||
projectDAL,
|
||||
orgDAL,
|
||||
userDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
@@ -110,7 +122,8 @@ export const pamAccountServiceFactory = ({
|
||||
description,
|
||||
folderId,
|
||||
rotationEnabled,
|
||||
rotationIntervalSeconds
|
||||
rotationIntervalSeconds,
|
||||
requireMfa
|
||||
}: TCreateAccountDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
@@ -198,7 +211,8 @@ export const pamAccountServiceFactory = ({
|
||||
description,
|
||||
folderId,
|
||||
rotationEnabled,
|
||||
rotationIntervalSeconds
|
||||
rotationIntervalSeconds,
|
||||
requireMfa
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -222,7 +236,15 @@ export const pamAccountServiceFactory = ({
|
||||
};
|
||||
|
||||
const updateById = async (
|
||||
{ accountId, credentials, description, name, rotationEnabled, rotationIntervalSeconds }: TUpdateAccountDTO,
|
||||
{
|
||||
accountId,
|
||||
credentials,
|
||||
description,
|
||||
name,
|
||||
rotationEnabled,
|
||||
rotationIntervalSeconds,
|
||||
requireMfa
|
||||
}: TUpdateAccountDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
@@ -268,6 +290,10 @@ export const pamAccountServiceFactory = ({
|
||||
updateDoc.name = name;
|
||||
}
|
||||
|
||||
if (requireMfa !== undefined) {
|
||||
updateDoc.requireMfa = requireMfa;
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
updateDoc.description = description;
|
||||
}
|
||||
@@ -552,7 +578,16 @@ export const pamAccountServiceFactory = ({
|
||||
};
|
||||
|
||||
const access = async (
|
||||
{ accountPath, projectId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO,
|
||||
{
|
||||
accountPath,
|
||||
projectId,
|
||||
actorEmail,
|
||||
actorIp,
|
||||
actorName,
|
||||
actorUserAgent,
|
||||
duration,
|
||||
mfaSessionId
|
||||
}: TAccessAccountDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
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(
|
||||
resource,
|
||||
account.projectId,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums";
|
||||
// DTOs
|
||||
export type TCreateAccountDTO = Pick<
|
||||
TPamAccount,
|
||||
"name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationIntervalSeconds"
|
||||
"name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationIntervalSeconds" | "requireMfa"
|
||||
> & {
|
||||
rotationEnabled?: boolean;
|
||||
};
|
||||
@@ -23,6 +23,7 @@ export type TAccessAccountDTO = {
|
||||
actorName: string;
|
||||
actorUserAgent: string;
|
||||
duration: number;
|
||||
mfaSessionId?: string;
|
||||
};
|
||||
|
||||
export type TListAccountsDTO = {
|
||||
|
||||
@@ -66,12 +66,14 @@ export const BaseCreatePamAccountSchema = z.object({
|
||||
name: slugSchema({ field: "name" }),
|
||||
description: z.string().max(512).nullable().optional(),
|
||||
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({
|
||||
name: slugSchema({ field: "name" }).optional(),
|
||||
description: z.string().max(512).nullable().optional(),
|
||||
rotationEnabled: z.boolean().optional(),
|
||||
rotationIntervalSeconds: z.number().min(3600).nullable().optional()
|
||||
rotationIntervalSeconds: z.number().min(3600).nullable().optional(),
|
||||
requireMfa: z.boolean().optional()
|
||||
});
|
||||
|
||||
@@ -81,6 +81,8 @@ export const KeyStorePrefixes = {
|
||||
`group-member-project-permission:${projectId}:${groupId}:*` 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,
|
||||
|
||||
@@ -94,7 +96,9 @@ export const KeyStoreTtls = {
|
||||
SetSecretSyncLastRunTimestampInSeconds: 60,
|
||||
AccessTokenStatusUpdateInSeconds: 120,
|
||||
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 = {
|
||||
|
||||
@@ -391,6 +391,9 @@ const envSchema = z
|
||||
})
|
||||
),
|
||||
|
||||
/* OracleDB ----------------------------------------------------------------------------- */
|
||||
TNS_ADMIN: zpStr(z.string().optional()),
|
||||
|
||||
/* INTERNAL ----------------------------------------------------------------------------- */
|
||||
INTERNAL_REGION: zpStr(z.enum(["us", "eu"]).optional())
|
||||
})
|
||||
|
||||
@@ -90,10 +90,23 @@ export class BadRequestError extends Error {
|
||||
|
||||
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");
|
||||
this.name = name || "BadRequest";
|
||||
this.error = error;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,9 +134,13 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
|
||||
}
|
||||
|
||||
if (error instanceof BadRequestError) {
|
||||
void res
|
||||
.status(HttpStatusCodes.BadRequest)
|
||||
.send({ reqId: req.id, statusCode: HttpStatusCodes.BadRequest, message: error.message, error: error.name });
|
||||
void res.status(HttpStatusCodes.BadRequest).send({
|
||||
reqId: req.id,
|
||||
statusCode: HttpStatusCodes.BadRequest,
|
||||
message: error.message,
|
||||
error: error.name,
|
||||
details: error.details
|
||||
});
|
||||
} else if (error instanceof NotFoundError) {
|
||||
void res
|
||||
.status(HttpStatusCodes.NotFound)
|
||||
|
||||
@@ -293,6 +293,7 @@ import { membershipIdentityDALFactory } from "@app/services/membership-identity/
|
||||
import { membershipIdentityServiceFactory } from "@app/services/membership-identity/membership-identity-service";
|
||||
import { membershipUserDALFactory } from "@app/services/membership-user/membership-user-dal";
|
||||
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 { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
|
||||
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 { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
|
||||
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 { webhookServiceFactory } from "@app/services/webhook/webhook-service";
|
||||
import { workflowIntegrationDALFactory } from "@app/services/workflow-integration/workflow-integration-dal";
|
||||
@@ -570,6 +573,7 @@ export const registerRoutes = async (
|
||||
const projectSlackConfigDAL = projectSlackConfigDALFactory(db);
|
||||
const workflowIntegrationDAL = workflowIntegrationDALFactory(db);
|
||||
const totpConfigDAL = totpConfigDALFactory(db);
|
||||
const webAuthnCredentialDAL = webAuthnCredentialDALFactory(db);
|
||||
|
||||
const externalGroupOrgRoleMappingDAL = externalGroupOrgRoleMappingDALFactory(db);
|
||||
|
||||
@@ -914,6 +918,13 @@ export const registerRoutes = async (
|
||||
kmsService
|
||||
});
|
||||
|
||||
const webAuthnService = webAuthnServiceFactory({
|
||||
webAuthnCredentialDAL,
|
||||
userDAL,
|
||||
tokenService,
|
||||
keyStore
|
||||
});
|
||||
|
||||
const loginService = authLoginServiceFactory({
|
||||
userDAL,
|
||||
smtpService,
|
||||
@@ -2451,6 +2462,13 @@ export const registerRoutes = async (
|
||||
gatewayV2Service
|
||||
});
|
||||
|
||||
const mfaSessionService = mfaSessionServiceFactory({
|
||||
keyStore,
|
||||
tokenService,
|
||||
smtpService,
|
||||
totpService
|
||||
});
|
||||
|
||||
const approvalPolicyDAL = approvalPolicyDALFactory(db);
|
||||
const pamSessionExpirationService = pamSessionExpirationServiceFactory({
|
||||
queueService,
|
||||
@@ -2467,8 +2485,12 @@ export const registerRoutes = async (
|
||||
pamSessionDAL,
|
||||
permissionService,
|
||||
projectDAL,
|
||||
orgDAL,
|
||||
userDAL,
|
||||
auditLogService,
|
||||
mfaSessionService,
|
||||
tokenService,
|
||||
smtpService,
|
||||
approvalRequestGrantsDAL,
|
||||
approvalPolicyDAL,
|
||||
pamSessionExpirationService
|
||||
@@ -2702,6 +2724,7 @@ export const registerRoutes = async (
|
||||
externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService,
|
||||
projectTemplate: projectTemplateService,
|
||||
totp: totpService,
|
||||
webAuthn: webAuthnService,
|
||||
appConnection: appConnectionService,
|
||||
secretSync: secretSyncService,
|
||||
kmip: kmipService,
|
||||
@@ -2723,6 +2746,7 @@ export const registerRoutes = async (
|
||||
pamResource: pamResourceService,
|
||||
pamAccount: pamAccountService,
|
||||
pamSession: pamSessionService,
|
||||
mfaSession: mfaSessionService,
|
||||
upgradePath: upgradePathService,
|
||||
|
||||
membershipUser: membershipUserService,
|
||||
|
||||
@@ -37,7 +37,8 @@ export const DefaultResponseErrorsSchema = {
|
||||
reqId: z.string(),
|
||||
statusCode: z.literal(400),
|
||||
message: z.string(),
|
||||
error: z.string()
|
||||
error: z.string(),
|
||||
details: z.any().optional()
|
||||
}),
|
||||
404: z.object({
|
||||
reqId: z.string(),
|
||||
|
||||
@@ -2,7 +2,6 @@ import { z } from "zod";
|
||||
|
||||
import {
|
||||
AccessScope,
|
||||
OrgMembershipRole,
|
||||
ProjectMembershipRole,
|
||||
ProjectMembershipsSchema,
|
||||
ProjectUserMembershipRolesSchema,
|
||||
@@ -275,7 +274,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
|
||||
orgId: req.permission.orgId
|
||||
},
|
||||
data: {
|
||||
roles: [{ isTemporary: false, role: OrgMembershipRole.NoAccess }],
|
||||
roles: [],
|
||||
usernames: usernamesAndEmails
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
|
||||
import { z } from "zod";
|
||||
|
||||
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 };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-
|
||||
import { registerDeprecatedProjectRouter } from "./deprecated-project-router";
|
||||
import { registerIdentityOrgRouter } from "./identity-org-router";
|
||||
import { registerMfaRouter } from "./mfa-router";
|
||||
import { registerMfaSessionRouter } from "./mfa-session-router";
|
||||
import { registerOrgRouter } from "./organization-router";
|
||||
import { registerPasswordRouter } from "./password-router";
|
||||
import { registerPkiAlertRouter } from "./pki-alert-router";
|
||||
@@ -17,6 +18,7 @@ import { registerUserRouter } from "./user-router";
|
||||
|
||||
export const registerV2Routes = async (server: FastifyZodProvider) => {
|
||||
await server.register(registerMfaRouter, { prefix: "/auth" });
|
||||
await server.register(registerMfaSessionRouter, { prefix: "/mfa-sessions" });
|
||||
await server.register(registerUserRouter, { prefix: "/users" });
|
||||
await server.register(registerServiceTokenRouter, { prefix: "/service-token" });
|
||||
await server.register(registerPasswordRouter, { prefix: "/password" });
|
||||
|
||||
@@ -186,4 +186,37 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
72
backend/src/server/routes/v2/mfa-session-router.ts
Normal file
72
backend/src/server/routes/v2/mfa-session-router.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import fs from "fs";
|
||||
import knex, { Knex } from "knex";
|
||||
import oracledb from "oracledb";
|
||||
|
||||
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
|
||||
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
||||
@@ -7,6 +9,7 @@ import {
|
||||
TSqlCredentialsRotationGeneratedCredentials,
|
||||
TSqlCredentialsRotationWithConnection
|
||||
} 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 { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
|
||||
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">) => {
|
||||
const {
|
||||
app,
|
||||
credentials: { host: baseHost, database, port, password, username }
|
||||
} = appConnection;
|
||||
|
||||
if (isOracleWalletConnection(app)) {
|
||||
return getOracleWalletKnexClient({ username, password, database });
|
||||
}
|
||||
|
||||
const [host] = await verifyHostInputValidity(baseHost);
|
||||
|
||||
const client = knex({
|
||||
@@ -119,21 +156,30 @@ export const executeWithPotentialGateway = async <T>(
|
||||
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) {
|
||||
return withGatewayV2Proxy(
|
||||
async (proxyPort) => {
|
||||
const client = 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 })
|
||||
}
|
||||
});
|
||||
const client = createClient(proxyPort);
|
||||
try {
|
||||
return await operation(client);
|
||||
} finally {
|
||||
@@ -154,18 +200,7 @@ export const executeWithPotentialGateway = async <T>(
|
||||
|
||||
return withGatewayProxy(
|
||||
async (proxyPort) => {
|
||||
const client = 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 })
|
||||
}
|
||||
});
|
||||
const client = createClient(proxyPort);
|
||||
try {
|
||||
return await operation(client);
|
||||
} finally {
|
||||
|
||||
@@ -74,6 +74,13 @@ export const getTokenConfig = (tokenType: TokenType) => {
|
||||
const expiresAt = new Date(new Date().getTime() + 259200000);
|
||||
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: {
|
||||
const token = crypto.randomBytes(16).toString("hex");
|
||||
const expiresAt = new Date();
|
||||
|
||||
@@ -8,7 +8,8 @@ export enum TokenType {
|
||||
TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation",
|
||||
TOKEN_EMAIL_PASSWORD_RESET = "passwordReset",
|
||||
TOKEN_EMAIL_PASSWORD_SETUP = "passwordSetup",
|
||||
TOKEN_USER_UNLOCK = "userUnlock"
|
||||
TOKEN_USER_UNLOCK = "userUnlock",
|
||||
TOKEN_WEBAUTHN_SESSION = "webauthnSession"
|
||||
}
|
||||
|
||||
export type TCreateTokenForUserDTO = {
|
||||
|
||||
@@ -882,6 +882,18 @@ export const authLoginServiceFactory = ({
|
||||
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) {
|
||||
const updatedUser = await processFailedMfaAttempt(userId);
|
||||
|
||||
@@ -100,5 +100,6 @@ export type AuthModeProviderSignUpTokenPayload = {
|
||||
|
||||
export enum MfaMethod {
|
||||
EMAIL = "email",
|
||||
TOTP = "totp"
|
||||
TOTP = "totp",
|
||||
WEBAUTHN = "webauthn"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AccessScope,
|
||||
OrgMembershipRole,
|
||||
OrgMembershipStatus,
|
||||
ProjectMembershipRole,
|
||||
TemporaryPermissionMode,
|
||||
@@ -157,13 +158,22 @@ export const membershipUserServiceFactory = ({
|
||||
const { scopeData, data } = dto;
|
||||
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) {
|
||||
throw new BadRequestError({
|
||||
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.temporaryAccessStartTime || !el.temporaryRange) {
|
||||
return true;
|
||||
@@ -188,7 +198,6 @@ export const membershipUserServiceFactory = ({
|
||||
});
|
||||
|
||||
if (existingMemberships.length === users.length) return { memberships: [] };
|
||||
const orgDetails = await orgDAL.findById(dto.permission.orgId);
|
||||
const isSubOrganization = Boolean(orgDetails.rootOrgId);
|
||||
|
||||
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;
|
||||
if (hasCustomRole) {
|
||||
const plan = await licenseService.getPlan(scopeData.orgId);
|
||||
@@ -241,7 +250,7 @@ export const membershipUserServiceFactory = ({
|
||||
|
||||
const roleDocs: TMembershipRolesInsert[] = [];
|
||||
docs.forEach((membership) => {
|
||||
data.roles.forEach((membershipRole) => {
|
||||
rolesToUse.forEach((membershipRole) => {
|
||||
const isCustomRole = Boolean(customRolesGroupBySlug?.[membershipRole.role]?.[0]);
|
||||
if (membershipRole.isTemporary) {
|
||||
const relativeTimeInMs = membershipRole.temporaryRange ? ms(membershipRole.temporaryRange) : null;
|
||||
|
||||
175
backend/src/services/mfa-session/mfa-session-service.ts
Normal file
175
backend/src/services/mfa-session/mfa-session-service.ts
Normal 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
|
||||
};
|
||||
};
|
||||
32
backend/src/services/mfa-session/mfa-session-types.ts
Normal file
32
backend/src/services/mfa-session/mfa-session-types.ts
Normal 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;
|
||||
};
|
||||
11
backend/src/services/webauthn/webauthn-credential-dal.ts
Normal file
11
backend/src/services/webauthn/webauthn-credential-dal.ts
Normal 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;
|
||||
};
|
||||
385
backend/src/services/webauthn/webauthn-service.ts
Normal file
385
backend/src/services/webauthn/webauthn-service.ts
Normal 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
|
||||
};
|
||||
};
|
||||
35
backend/src/services/webauthn/webauthn-types.ts
Normal file
35
backend/src/services/webauthn/webauthn-types.ts
Normal 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;
|
||||
};
|
||||
@@ -44,17 +44,113 @@ Infisical supports connecting to OracleDB using a database user.
|
||||
</Tabs>
|
||||
</Step>
|
||||
<Step title="Get Connection Details">
|
||||
You'll need the following information to create your Oracle Database connection:
|
||||
- `host` - The hostname or IP address of your Oracle Database server
|
||||
- `port` - The port number your Oracle Database server is listening on (default: 1521)
|
||||
- `database` - The Oracle Service Name or SID (System Identifier) for the database you are connecting to. For example: `ORCL`, `FREEPDB1`, `XEPDB1`
|
||||
- `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)
|
||||
<Tabs>
|
||||
<Tab title="One-way TLS">
|
||||
You'll need the following information to create your Oracle Database connection:
|
||||
- `host` - The hostname or IP address of your Oracle Database server
|
||||
- `port` - The port number your Oracle Database server is listening on (default: 1521)
|
||||
- `database` - The Oracle Service Name or SID (System Identifier) for the database you are connecting to. For example: `ORCL`, `FREEPDB1`, `XEPDB1`
|
||||
- `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>
|
||||
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>
|
||||
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>
|
||||
<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>
|
||||
</Steps>
|
||||
|
||||
|
||||
@@ -970,6 +970,13 @@ Please refer to the [templating functions documentation](/integrations/platforms
|
||||
|
||||
</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
|
||||
|
||||
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 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
|
||||
|
||||
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
|
||||
|
||||
The operator will transfer all labels & annotations present on the `InfisicalSecret` CRD to the managed Kubernetes secret to be created.
|
||||
Thus, if a specific label is required on the resulting secret, it can be applied as demonstrated in the following example:
|
||||
The operator provides flexible options for managing labels and annotations on managed Kubernetes secrets.
|
||||
|
||||
<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
|
||||
apiVersion: secrets.infisical.com/v1alpha1
|
||||
kind: InfisicalSecret
|
||||
@@ -1578,28 +1595,96 @@ Thus, if a specific label is required on the resulting secret, it can be applied
|
||||
annotations:
|
||||
example.com/annotation-to-be-passed-to-managed-secret: "sample-value"
|
||||
spec:
|
||||
..
|
||||
authentication:
|
||||
...
|
||||
# ... auth config ...
|
||||
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
|
||||
apiVersion: v1
|
||||
data: ...
|
||||
kind: Secret
|
||||
metadata:
|
||||
annotations:
|
||||
example.com/annotation-to-be-passed-to-managed-secret: sample-value
|
||||
secrets.infisical.com/version: W/"3f1-ZyOSsrCLGSkAhhCkY2USPu2ivRw"
|
||||
labels:
|
||||
label-to-be-passed-to-managed-secret: sample-value
|
||||
name: managed-token
|
||||
namespace: default
|
||||
type: Opaque
|
||||
```
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
data: ...
|
||||
kind: Secret
|
||||
metadata:
|
||||
annotations:
|
||||
example.com/annotation-to-be-passed-to-managed-secret: sample-value
|
||||
secrets.infisical.com/version: W/"3f1-ZyOSsrCLGSkAhhCkY2USPu2ivRw"
|
||||
labels:
|
||||
label-to-be-passed-to-managed-secret: sample-value
|
||||
name: managed-token
|
||||
namespace: default
|
||||
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>
|
||||
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -48,6 +48,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.5",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-router": "^1.95.1",
|
||||
@@ -4141,6 +4142,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.5",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-router": "^1.95.1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ReactCodeInput from "react-code-input";
|
||||
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { t } from "i18next";
|
||||
|
||||
@@ -7,11 +8,23 @@ import Error from "@app/components/basic/Error";
|
||||
import TotpRegistration from "@app/components/mfa/TotpRegistration";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
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 { 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 { getMfaTempToken } from "@app/hooks/api/reactQuery";
|
||||
import {
|
||||
useGenerateAuthenticationOptions,
|
||||
useGenerateRegistrationOptions,
|
||||
useVerifyAuthentication,
|
||||
useVerifyRegistration
|
||||
} from "@app/hooks/api/webauthn";
|
||||
|
||||
// The style for the verification code input
|
||||
const codeInputProps = {
|
||||
@@ -68,9 +81,17 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
|
||||
const [isLoadingResend, setIsLoadingResend] = useState(false);
|
||||
const [triesLeft, setTriesLeft] = useState<number | undefined>(undefined);
|
||||
const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false);
|
||||
const [shouldShowWebAuthnRegistration, setShouldShowWebAuthnRegistration] = useState(false);
|
||||
const [credentialName, setCredentialName] = useState("");
|
||||
const [isRegisteringPasskey, setIsRegisteringPasskey] = useState(false);
|
||||
const logout = useLogoutUser(true);
|
||||
|
||||
const { mutateAsync: generateWebAuthnAuthenticationOptions } = useGenerateAuthenticationOptions();
|
||||
const { mutateAsync: verifyWebAuthnAuthentication } = useVerifyAuthentication();
|
||||
|
||||
const sendMfaToken = useSendMfaToken();
|
||||
const generateRegistrationOptions = useGenerateRegistrationOptions();
|
||||
const verifyRegistration = useVerifyRegistration();
|
||||
|
||||
useEffect(() => {
|
||||
if (method === MfaMethod.TOTP) {
|
||||
@@ -80,8 +101,14 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
|
||||
setShouldShowTotpRegistration(true);
|
||||
}
|
||||
});
|
||||
} else if (method === MfaMethod.WEBAUTHN) {
|
||||
checkUserWebAuthnMfa().then((hasPasskeys) => {
|
||||
if (!hasPasskeys) {
|
||||
setShouldShowWebAuthnRegistration(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [method]);
|
||||
|
||||
const getExpectedCodeLength = () => {
|
||||
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) {
|
||||
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'll be prompted to use
|
||||
your device'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 (
|
||||
<div className="mx-auto w-max pt-6 pb-6 md:mb-16 md:px-8">
|
||||
{!hideLogo && (
|
||||
@@ -197,83 +400,113 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
{method === MfaMethod.WEBAUTHN && (
|
||||
<div className="mb-8 text-center">
|
||||
<h2 className="mb-3 text-xl font-medium text-bunker-100">Passkey Authentication</h2>
|
||||
<p className="mx-auto max-w-md text-sm leading-relaxed text-bunker-300">
|
||||
Use your registered passkey to complete two-factor authentication
|
||||
</p>
|
||||
</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.WEBAUTHN ? (
|
||||
<>
|
||||
{typeof triesLeft === "number" && (
|
||||
<Error text={`Failed authentication. You have ${triesLeft} attempt(s) remaining.`} />
|
||||
)}
|
||||
{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 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}
|
||||
isDisabled={typeof triesLeft === "number" && triesLeft <= 0}
|
||||
>
|
||||
Authenticate with Passkey
|
||||
</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>
|
||||
{typeof triesLeft === "number" && (
|
||||
<Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} />
|
||||
)}
|
||||
<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 || (typeof triesLeft === "number" && triesLeft <= 0)}
|
||||
>
|
||||
{String(t("mfa.verify"))}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<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 || (typeof triesLeft === "number" && triesLeft <= 0)}
|
||||
>
|
||||
{String(t("mfa.verify"))}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{method === MfaMethod.TOTP && (
|
||||
<div className="mt-6 flex flex-col items-center gap-4 text-sm">
|
||||
<button
|
||||
|
||||
@@ -28,12 +28,12 @@ const SCOPE_BADGE: Record<NonNullable<Props["scope"]>, { icon: LucideIcon; class
|
||||
};
|
||||
|
||||
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="mr-4 flex min-w-0 flex-1 items-center">
|
||||
<h1
|
||||
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 === "instance" && "decoration-neutral/90",
|
||||
scope === "namespace" && "decoration-sub-org/90",
|
||||
|
||||
@@ -10,7 +10,7 @@ function UnstableAccordion({ ...props }: React.ComponentProps<typeof AccordionPr
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "cva";
|
||||
import { cn } from "../../utils";
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
|
||||
@@ -51,6 +51,7 @@ function UnstableTableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b border-border transition-colors hover:bg-foreground/5 data-[state=selected]:bg-foreground/5",
|
||||
props.onClick && "cursor-pointer",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -11,11 +11,11 @@ export {
|
||||
ProjectPermissionGroupActions,
|
||||
ProjectPermissionIdentityActions,
|
||||
ProjectPermissionKmipActions,
|
||||
ProjectPermissionMcpEndpointActions,
|
||||
ProjectPermissionMemberActions,
|
||||
ProjectPermissionPkiSubscriberActions,
|
||||
ProjectPermissionPkiSyncActions,
|
||||
ProjectPermissionPkiTemplateActions,
|
||||
ProjectPermissionSshHostActions,
|
||||
ProjectPermissionMcpEndpointActions,
|
||||
ProjectPermissionSub
|
||||
} from "./types";
|
||||
|
||||
@@ -22,12 +22,12 @@ export {
|
||||
ProjectPermissionGroupActions,
|
||||
ProjectPermissionIdentityActions,
|
||||
ProjectPermissionKmipActions,
|
||||
ProjectPermissionMcpEndpointActions,
|
||||
ProjectPermissionMemberActions,
|
||||
ProjectPermissionPkiSubscriberActions,
|
||||
ProjectPermissionPkiSyncActions,
|
||||
ProjectPermissionPkiTemplateActions,
|
||||
ProjectPermissionSshHostActions,
|
||||
ProjectPermissionMcpEndpointActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission
|
||||
} from "./ProjectPermissionContext";
|
||||
|
||||
16
frontend/src/helpers/mfaSession.ts
Normal file
16
frontend/src/helpers/mfaSession.ts
Normal 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";
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { SessionStorageKeys } from "@app/const";
|
||||
import { organizationKeys } from "../organization/queries";
|
||||
import { projectKeys } from "../projects";
|
||||
import { setAuthToken } from "../reactQuery";
|
||||
import { TGenerateAuthenticationOptionsResponse, TVerifyAuthenticationDTO } from "../webauthn";
|
||||
import {
|
||||
CompleteAccountDTO,
|
||||
CompleteAccountSignupDTO,
|
||||
@@ -333,6 +334,36 @@ export const checkUserTotpMfa = async () => {
|
||||
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 = () => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
|
||||
@@ -149,5 +149,6 @@ export enum UserAgentType {
|
||||
|
||||
export enum MfaMethod {
|
||||
EMAIL = "email",
|
||||
TOTP = "totp"
|
||||
TOTP = "totp",
|
||||
WEBAUTHN = "webauthn"
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export * from "./integrationAuth";
|
||||
export * from "./integrations";
|
||||
export * from "./kms";
|
||||
export * from "./ldapConfig";
|
||||
export * from "./mfaSession";
|
||||
export * from "./oidcConfig";
|
||||
export * from "./orgAdmin";
|
||||
export * from "./organization";
|
||||
|
||||
7
frontend/src/hooks/api/mfaSession/index.tsx
Normal file
7
frontend/src/hooks/api/mfaSession/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export { useMfaSessionStatus, useVerifyMfaSession } from "./queries";
|
||||
export type {
|
||||
TMfaSessionStatusResponse,
|
||||
TVerifyMfaSessionRequest,
|
||||
TVerifyMfaSessionResponse
|
||||
} from "./types";
|
||||
export { MfaSessionStatus } from "./types";
|
||||
45
frontend/src/hooks/api/mfaSession/queries.tsx
Normal file
45
frontend/src/hooks/api/mfaSession/queries.tsx
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
22
frontend/src/hooks/api/mfaSession/types.ts
Normal file
22
frontend/src/hooks/api/mfaSession/types.ts
Normal 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;
|
||||
};
|
||||
@@ -14,7 +14,8 @@ export interface TBasePamAccount {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
rotationEnabled: boolean;
|
||||
rotationIntervalSeconds?: number | null;
|
||||
rotationIntervalSeconds?: number;
|
||||
requireMfa?: boolean | null;
|
||||
lastRotatedAt?: string | null;
|
||||
lastRotationMessage?: string | null;
|
||||
rotationStatus?: string | null;
|
||||
|
||||
@@ -143,13 +143,13 @@ export type TListPamAccountsDTO = {
|
||||
|
||||
export type TCreatePamAccountDTO = Pick<
|
||||
TPamAccount,
|
||||
"name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId"
|
||||
"name" | "description" | "credentials" | "projectId" | "resourceId" | "folderId" | "requireMfa"
|
||||
> & {
|
||||
resourceType: PamResourceType;
|
||||
};
|
||||
|
||||
export type TUpdatePamAccountDTO = Partial<
|
||||
Pick<TPamAccount, "name" | "description" | "credentials">
|
||||
Pick<TPamAccount, "name" | "description" | "credentials" | "requireMfa">
|
||||
> & {
|
||||
accountId: string;
|
||||
resourceType: PamResourceType;
|
||||
|
||||
18
frontend/src/hooks/api/webauthn/index.tsx
Normal file
18
frontend/src/hooks/api/webauthn/index.tsx
Normal 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";
|
||||
96
frontend/src/hooks/api/webauthn/mutations.tsx
Normal file
96
frontend/src/hooks/api/webauthn/mutations.tsx
Normal 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 });
|
||||
}
|
||||
});
|
||||
};
|
||||
22
frontend/src/hooks/api/webauthn/queries.tsx
Normal file
22
frontend/src/hooks/api/webauthn/queries.tsx
Normal 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;
|
||||
}
|
||||
});
|
||||
37
frontend/src/hooks/api/webauthn/types.ts
Normal file
37
frontend/src/hooks/api/webauthn/types.ts
Normal 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;
|
||||
};
|
||||
311
frontend/src/pages/MfaSessionPage/MfaSessionPage.tsx
Normal file
311
frontend/src/pages/MfaSessionPage/MfaSessionPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
frontend/src/pages/MfaSessionPage/index.tsx
Normal file
1
frontend/src/pages/MfaSessionPage/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { MfaSessionPage } from "./MfaSessionPage";
|
||||
7
frontend/src/pages/MfaSessionPage/route.tsx
Normal file
7
frontend/src/pages/MfaSessionPage/route.tsx
Normal 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
|
||||
});
|
||||
@@ -236,7 +236,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr className="h-14">
|
||||
<Th className="w-1/2">
|
||||
<Th className="w-2/3">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
@@ -309,7 +309,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
})
|
||||
}
|
||||
>
|
||||
<Td className="group">
|
||||
<Td className="group max-w-0 truncate">
|
||||
{name}
|
||||
{lastLoginAuthMethod && lastLoginTime && (
|
||||
<Tooltip
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
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 { ChevronLeftIcon, EllipsisIcon } from "lucide-react";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
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 {
|
||||
OrgPermissionActions,
|
||||
@@ -36,7 +51,7 @@ const Page = () => {
|
||||
const { currentOrg, isSubOrganization } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { data } = useGetOrgIdentityMembershipById(identityId);
|
||||
const { mutateAsync: deleteIdentity, isPending: isDeletingIdentity } = useDeleteOrgIdentity();
|
||||
const { mutateAsync: deleteIdentity } = useDeleteOrgIdentity();
|
||||
const isAuthHidden = orgId !== data?.identity?.orgId;
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
@@ -68,41 +83,55 @@ const Page = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const isScopeIdentity = data?.identity.orgId === currentOrg.id;
|
||||
|
||||
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 && (
|
||||
<div className="mx-auto w-full max-w-8xl">
|
||||
<>
|
||||
<Link
|
||||
to="/organizations/$orgId/access-management"
|
||||
params={{ orgId }}
|
||||
search={{
|
||||
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} />
|
||||
Organization Machine Identities
|
||||
<ChevronLeftIcon size={16} />
|
||||
{isSubOrganization ? "Sub-" : ""}Organization Machine Identities
|
||||
</Link>
|
||||
<PageHeader
|
||||
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}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSubOrganization && data.identity.orgId !== currentOrg.id && (
|
||||
<UnstableDropdownMenu>
|
||||
<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
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
renderTooltip
|
||||
allowedLabel="Remove from sub-organization"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
<UnstableDropdownMenuItem
|
||||
variant="danger"
|
||||
isDisabled={!isAllowed}
|
||||
isLoading={isDeletingIdentity}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("deleteIdentity", {
|
||||
identityId: data.identity.id,
|
||||
@@ -110,23 +139,64 @@ const Page = () => {
|
||||
})
|
||||
}
|
||||
>
|
||||
Unlink Machine Identity
|
||||
</Button>
|
||||
{isScopeIdentity ? "Delete Machine Identity" : "Remove From Sub-Organization"}
|
||||
</UnstableDropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
</UnstableDropdownMenuContent>
|
||||
</UnstableDropdownMenu>
|
||||
</PageHeader>
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<div className="w-full md:w-96">
|
||||
<IdentityDetailsSection
|
||||
isOrgIdentity={data.identity.orgId === currentOrg.id}
|
||||
identityId={identityId}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
{!isAuthHidden && (
|
||||
<div className="flex flex-col gap-5 lg:flex-row">
|
||||
<IdentityDetailsSection
|
||||
isCurrentOrgIdentity={data.identity.orgId === currentOrg.id}
|
||||
identityId={identityId}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-y-5">
|
||||
{isAuthHidden ? (
|
||||
<UnstableCard>
|
||||
<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'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
|
||||
identityId={identityId}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
@@ -135,7 +205,7 @@ const Page = () => {
|
||||
<IdentityProjectsSection identityId={identityId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Modal
|
||||
isOpen={popUp?.identity?.isOpen}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context";
|
||||
import {
|
||||
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 { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
@@ -20,51 +32,92 @@ type Props = {
|
||||
export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: Props) => {
|
||||
const { data, refetch } = useGetOrgIdentityMembershipById(identityId);
|
||||
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
const hasAuthMethods = Boolean(data?.identity.authMethods.length);
|
||||
|
||||
return data ? (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Authentication</h3>
|
||||
{!Object.values(IdentityAuthMethod).every((method) =>
|
||||
data.identity.authMethods.includes(method)
|
||||
) && (
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionIdentityActions.Edit}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("identityAuthMethod", {
|
||||
identityId,
|
||||
name: data.identity.name,
|
||||
allAuthMethods: data.identity.authMethods
|
||||
});
|
||||
}}
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
<UnstableCard>
|
||||
<UnstableCardHeader>
|
||||
<UnstableCardTitle>Authentication</UnstableCardTitle>
|
||||
<UnstableCardDescription>Configure authentication methods</UnstableCardDescription>
|
||||
{hasAuthMethods &&
|
||||
!Object.values(IdentityAuthMethod).every((method) =>
|
||||
data.identity.authMethods.includes(method)
|
||||
) && (
|
||||
<UnstableCardAction>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionIdentityActions.Edit}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
>
|
||||
{data.identity.authMethods.length ? "Add" : "Create"} Auth Method
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
{(isAllowed) => (
|
||||
<UnstableButton
|
||||
variant="outline"
|
||||
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>
|
||||
{data.identity.authMethods.length > 0 ? (
|
||||
<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>
|
||||
</UnstableCardContent>
|
||||
</UnstableCard>
|
||||
) : (
|
||||
<div />
|
||||
);
|
||||
|
||||
@@ -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 { twMerge } from "tailwind-merge";
|
||||
import { BanIcon, CheckIcon, ClipboardListIcon, PencilIcon } from "lucide-react";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
Badge,
|
||||
Detail,
|
||||
DetailGroup,
|
||||
DetailLabel,
|
||||
DetailValue,
|
||||
OrgIcon,
|
||||
SubOrgIcon,
|
||||
UnstableButtonGroup,
|
||||
UnstableCard,
|
||||
UnstableCardAction,
|
||||
UnstableCardContent,
|
||||
UnstableCardDescription,
|
||||
UnstableCardHeader,
|
||||
UnstableCardTitle,
|
||||
UnstableIconButton
|
||||
} from "@app/components/v3";
|
||||
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import { identityAuthToNameMap, useGetOrgIdentityMembershipById } from "@app/hooks/api";
|
||||
@@ -28,187 +27,171 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
identityId: string;
|
||||
isOrgIdentity?: boolean;
|
||||
isCurrentOrgIdentity?: boolean;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["identity", "identityAuthMethod", "deleteIdentity"]>,
|
||||
data?: object
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const IdentityDetailsSection = ({ identityId, handlePopUpOpen, isOrgIdentity }: Props) => {
|
||||
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
|
||||
export const IdentityDetailsSection = ({
|
||||
identityId,
|
||||
handlePopUpOpen,
|
||||
isCurrentOrgIdentity
|
||||
}: Props) => {
|
||||
const [, isCopyingId, setCopyTextId] = useTimedReset<string>({
|
||||
initialState: "Copy ID to clipboard"
|
||||
});
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
const { data } = useGetOrgIdentityMembershipById(identityId);
|
||||
|
||||
return data ? (
|
||||
<div className="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">
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Details</h3>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="xs"
|
||||
rightIcon={
|
||||
<FontAwesomeIcon
|
||||
className="ml-1 transition-transform duration-200 group-data-[state=open]:rotate-180"
|
||||
icon={faChevronDown}
|
||||
/>
|
||||
}
|
||||
colorSchema="secondary"
|
||||
className="group select-none"
|
||||
>
|
||||
Options
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="mt-3 min-w-[120px]" align="end">
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionIdentityActions.Edit}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
onClick={async () => {
|
||||
handlePopUpOpen("identity", {
|
||||
identityId,
|
||||
name: data.identity.name,
|
||||
orgId: data.identity.orgId,
|
||||
hasDeleteProtection: data.identity.hasDeleteProtection,
|
||||
role: data.role,
|
||||
customRole: data.customRole,
|
||||
metadata: data.metadata
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
{isOrgIdentity ? "Edit Machine Identity" : "Edit Machine Identity Role"}
|
||||
</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"
|
||||
<UnstableCard className="w-full lg:max-w-[24rem]">
|
||||
<UnstableCardHeader className="border-b">
|
||||
<UnstableCardTitle>Details</UnstableCardTitle>
|
||||
<UnstableCardDescription>Machine identity details</UnstableCardDescription>
|
||||
<UnstableCardAction>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionIdentityActions.Edit}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<UnstableIconButton
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("identity", {
|
||||
identityId,
|
||||
name: data.identity.name,
|
||||
orgId: data.identity.orgId,
|
||||
hasDeleteProtection: data.identity.hasDeleteProtection,
|
||||
role: data.role,
|
||||
customRole: data.customRole,
|
||||
metadata: data.metadata
|
||||
});
|
||||
}}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
<PencilIcon />
|
||||
</UnstableIconButton>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</UnstableCardAction>
|
||||
</UnstableCardHeader>
|
||||
<UnstableCardContent>
|
||||
<DetailGroup>
|
||||
<Detail>
|
||||
<DetailLabel>Name</DetailLabel>
|
||||
<DetailValue>{data.identity.name}</DetailValue>
|
||||
</Detail>
|
||||
<Detail>
|
||||
<DetailLabel>ID</DetailLabel>
|
||||
<DetailValue className="flex items-center gap-x-1">
|
||||
{data.identity.id}
|
||||
<Tooltip content="Copy machine identity ID to clipboard">
|
||||
<UnstableIconButton
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(data.identity.id);
|
||||
setCopyTextId("Copied");
|
||||
}}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
{/* TODO(scott): color this should be a button variant and create re-usable copy button */}
|
||||
{isCopyingId ? <CheckIcon /> : <ClipboardListIcon className="text-label" />}
|
||||
</UnstableIconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Name</p>
|
||||
<p className="text-sm text-mineshaft-300">{data.identity.name}</p>
|
||||
</div>
|
||||
{isSubOrganization && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Managed By</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{isOrgIdentity ? "Organization" : "Root Organization"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isOrgIdentity && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Last Login Auth Method</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{data.lastLoginAuthMethod ? identityAuthToNameMap[data.lastLoginAuthMethod] : "-"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isOrgIdentity && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Last Login Time</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{data.lastLoginTime ? format(data.lastLoginTime, "PPpp") : "-"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isOrgIdentity && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Delete Protection</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{data.identity.hasDeleteProtection ? "On" : "Off"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-mineshaft-300">Organization Role</p>
|
||||
<p className="text-sm text-mineshaft-300">{data.role}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-mineshaft-300">Metadata</p>
|
||||
{data?.metadata?.length ? (
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300">
|
||||
{data.metadata?.map((el) => (
|
||||
<div key={el.id} className="flex items-center">
|
||||
<Tag
|
||||
size="xs"
|
||||
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
|
||||
<div>{el.key}</div>
|
||||
</Tag>
|
||||
<Tag
|
||||
size="xs"
|
||||
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
|
||||
>
|
||||
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{el.value}
|
||||
</div>
|
||||
</Tag>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-mineshaft-300">-</p>
|
||||
</DetailValue>
|
||||
</Detail>
|
||||
<Detail>
|
||||
<DetailLabel>Managed by</DetailLabel>
|
||||
<DetailValue>
|
||||
{isSubOrganization && isCurrentOrgIdentity ? (
|
||||
<Badge variant="sub-org">
|
||||
<SubOrgIcon />
|
||||
Sub-Organization
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="org">
|
||||
<OrgIcon />
|
||||
Organization
|
||||
</Badge>
|
||||
)}
|
||||
</DetailValue>
|
||||
</Detail>
|
||||
<Detail>
|
||||
<DetailLabel>Organization Role</DetailLabel>
|
||||
<DetailValue>{data.role}</DetailValue>
|
||||
</Detail>
|
||||
<Detail>
|
||||
<DetailLabel>Metadata</DetailLabel>
|
||||
<DetailValue className="flex flex-wrap gap-2">
|
||||
{data?.metadata?.length ? (
|
||||
data.metadata?.map((el) => (
|
||||
<UnstableButtonGroup className="min-w-0" key={el.id}>
|
||||
<Badge isTruncatable>
|
||||
<span>{el.key}</span>
|
||||
</Badge>
|
||||
<Badge variant="outline" isTruncatable>
|
||||
<span>{el.value}</span>
|
||||
</Badge>
|
||||
</UnstableButtonGroup>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</DetailValue>
|
||||
</Detail>
|
||||
<Detail>
|
||||
<DetailLabel>
|
||||
{isSubOrganization && !isCurrentOrgIdentity ? "Joined sub-organization" : "Created"}
|
||||
</DetailLabel>
|
||||
<DetailValue>{format(data.createdAt, "PPpp")}</DetailValue>
|
||||
</Detail>
|
||||
{isCurrentOrgIdentity && (
|
||||
<>
|
||||
<Detail>
|
||||
<DetailLabel>Last Login Method</DetailLabel>
|
||||
<DetailValue>
|
||||
{data.lastLoginAuthMethod ? (
|
||||
identityAuthToNameMap[data.lastLoginAuthMethod]
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</DetailValue>
|
||||
</Detail>
|
||||
<Detail>
|
||||
<DetailLabel>Last Logged In</DetailLabel>
|
||||
<DetailValue>
|
||||
{data.lastLoginTime ? (
|
||||
format(data.lastLoginTime, "PPpp")
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</DetailValue>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</DetailGroup>
|
||||
</UnstableCardContent>
|
||||
</UnstableCard>
|
||||
) : (
|
||||
<div />
|
||||
);
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
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 { format } from "date-fns";
|
||||
import { MoreHorizontalIcon } from "lucide-react";
|
||||
|
||||
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 { getProjectBaseURL } from "@app/helpers/project";
|
||||
import { formatProjectRoleName } from "@app/helpers/roles";
|
||||
@@ -47,8 +54,7 @@ export const IdentityProjectRow = ({
|
||||
}, [workspaces, project]);
|
||||
|
||||
return (
|
||||
<Tr
|
||||
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
<UnstableTableRow
|
||||
key={`identity-project-membership-${id}`}
|
||||
onClick={() => {
|
||||
if (isAccessible) {
|
||||
@@ -71,36 +77,55 @@ export const IdentityProjectRow = ({
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Td className="max-w-0 truncate">{project.name}</Td>
|
||||
<Td>{`${formatProjectRoleName(roles[0].role, roles[0].customRoleName)}${
|
||||
<UnstableTableCell className="max-w-0 truncate">{project.name}</UnstableTableCell>
|
||||
<UnstableTableCell>{`${formatProjectRoleName(roles[0].role, roles[0].customRoleName)}${
|
||||
roles.length > 1 ? ` (+${roles.length - 1})` : ""
|
||||
}`}</Td>
|
||||
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
||||
<Td>
|
||||
{isAccessible && (
|
||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<Tooltip content="Remove">
|
||||
<IconButton
|
||||
colorSchema="danger"
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("removeIdentityFromProject", {
|
||||
identityId: identity.id,
|
||||
identityName: identity.name,
|
||||
projectId: project.id,
|
||||
projectName: project.name
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
}`}</UnstableTableCell>
|
||||
<UnstableTableCell>{format(new Date(createdAt), "yyyy-MM-dd")}</UnstableTableCell>
|
||||
<UnstableTableCell>
|
||||
<UnstableDropdownMenu>
|
||||
<UnstableDropdownMenuTrigger>
|
||||
<UnstableIconButton variant="ghost" size="xs">
|
||||
<MoreHorizontalIcon />
|
||||
</UnstableIconButton>
|
||||
</UnstableDropdownMenuTrigger>
|
||||
<UnstableDropdownMenuContent align="end">
|
||||
<UnstableDropdownMenuItem
|
||||
isDisabled={!isAccessible}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: `${getProjectBaseURL(project.type)}/access-management` as const,
|
||||
params: {
|
||||
orgId: currentOrg?.id || "",
|
||||
projectId: project.id
|
||||
},
|
||||
search: {
|
||||
selectedTab: TabSections.Identities
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
Access Project
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal, IconButton } from "@app/components/v2";
|
||||
import { useDeleteProjectIdentityMembership } from "@app/hooks/api";
|
||||
import { DeleteActionModal } from "@app/components/v2";
|
||||
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 { IdentityAddToProjectModal } from "./IdentityAddToProjectModal";
|
||||
@@ -35,24 +46,35 @@ export const IdentityProjectsSection = ({ identityId }: Props) => {
|
||||
handlePopUpClose("removeIdentityFromProject");
|
||||
};
|
||||
|
||||
const { data: projectMemberships } = useGetIdentityProjectMemberships(identityId);
|
||||
|
||||
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">
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Projects</h3>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addIdentityToProject");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<IdentityProjectsTable identityId={identityId} handlePopUpOpen={handlePopUpOpen} />
|
||||
</div>
|
||||
<>
|
||||
<UnstableCard>
|
||||
<UnstableCardHeader>
|
||||
<UnstableCardTitle>Projects</UnstableCardTitle>
|
||||
<UnstableCardDescription>
|
||||
Manage machine identity project memberships
|
||||
</UnstableCardDescription>
|
||||
{Boolean(projectMemberships?.length) && (
|
||||
<UnstableCardAction>
|
||||
<UnstableButton
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addIdentityToProject");
|
||||
}}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon />
|
||||
Add to Project
|
||||
</UnstableButton>
|
||||
</UnstableCardAction>
|
||||
)}
|
||||
</UnstableCardHeader>
|
||||
<UnstableCardContent>
|
||||
<IdentityProjectsTable identityId={identityId} handlePopUpOpen={handlePopUpOpen} />
|
||||
</UnstableCardContent>
|
||||
</UnstableCard>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeIdentityFromProject.isOpen}
|
||||
title={`Are you sure you want to remove ${
|
||||
@@ -76,6 +98,6 @@ export const IdentityProjectsSection = ({ identityId }: Props) => {
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faFolder,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { ChevronDownIcon, PlusIcon } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Lottie } from "@app/components/v2";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
UnstableButton,
|
||||
UnstableEmpty,
|
||||
UnstableEmptyContent,
|
||||
UnstableEmptyDescription,
|
||||
UnstableEmptyHeader,
|
||||
UnstableEmptyTitle,
|
||||
UnstableInput,
|
||||
UnstablePagination,
|
||||
UnstableTable,
|
||||
UnstableTableBody,
|
||||
UnstableTableHead,
|
||||
UnstableTableHeader,
|
||||
UnstableTableRow
|
||||
} from "@app/components/v3";
|
||||
import { useOrganization } from "@app/context";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
@@ -36,7 +34,7 @@ import { IdentityProjectRow } from "./IdentityProjectRow";
|
||||
type Props = {
|
||||
identityId: string;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["removeIdentityFromProject"]>,
|
||||
popUpName: keyof UsePopUpState<["removeIdentityFromProject", "addIdentityToProject"]>,
|
||||
data?: object
|
||||
) => void;
|
||||
};
|
||||
@@ -48,6 +46,8 @@ enum IdentityProjectsOrderBy {
|
||||
export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => {
|
||||
const { data: projectMemberships = [], isPending } = useGetIdentityProjectMemberships(identityId);
|
||||
|
||||
const { isSubOrganization } = useOrganization();
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
@@ -90,41 +90,43 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
|
||||
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 (
|
||||
<div>
|
||||
<Input
|
||||
<>
|
||||
<UnstableInput
|
||||
className="mb-4"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search projects..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-2/3">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className="ml-2"
|
||||
ariaLabel="sort"
|
||||
onClick={toggleOrderDirection}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
{filteredProjectMemberships.length ? (
|
||||
<UnstableTable>
|
||||
<UnstableTableHeader>
|
||||
<UnstableTableRow>
|
||||
<UnstableTableHead onClick={toggleOrderDirection} className="w-1/3">
|
||||
Name
|
||||
<ChevronDownIcon
|
||||
className={twMerge(
|
||||
orderDirection === OrderByDirection.DESC && "rotate-180",
|
||||
"transition-transform"
|
||||
)}
|
||||
/>
|
||||
</UnstableTableHead>
|
||||
|
||||
<Th>Role</Th>
|
||||
<Th>Added On</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="identity-project-memberships" />}
|
||||
<UnstableTableHead>Role</UnstableTableHead>
|
||||
<UnstableTableHead>Added On</UnstableTableHead>
|
||||
<UnstableTableHead className="w-5" />
|
||||
</UnstableTableRow>
|
||||
</UnstableTableHeader>
|
||||
<UnstableTableBody>
|
||||
{!isPending &&
|
||||
filteredProjectMemberships.slice(offset, perPage * page).map((membership) => {
|
||||
return (
|
||||
@@ -135,28 +137,45 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredProjectMemberships.length) && (
|
||||
<Pagination
|
||||
count={filteredProjectMemberships.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredProjectMemberships?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
projectMemberships.length
|
||||
? "No projects match search..."
|
||||
: "This machine identity has not been assigned to any projects"
|
||||
}
|
||||
icon={projectMemberships.length ? faSearch : faFolder}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
</UnstableTableBody>
|
||||
</UnstableTable>
|
||||
) : (
|
||||
<UnstableEmpty className="border">
|
||||
<UnstableEmptyHeader>
|
||||
<UnstableEmptyTitle>
|
||||
{projectMemberships.length
|
||||
? "No projects match this search"
|
||||
: "This machine identity is not a member of any projects"}
|
||||
</UnstableEmptyTitle>
|
||||
<UnstableEmptyDescription>
|
||||
{projectMemberships.length
|
||||
? "Adjust search filters to view project memberships."
|
||||
: "Assign this machine identity to a project."}
|
||||
</UnstableEmptyDescription>
|
||||
{!projectMemberships.length && (
|
||||
<UnstableEmptyContent>
|
||||
<UnstableButton
|
||||
variant={isSubOrganization ? "sub-org" : "org"}
|
||||
size="xs"
|
||||
onClick={() => handlePopUpOpen("addIdentityToProject")}
|
||||
>
|
||||
<PlusIcon />
|
||||
Add to Project
|
||||
</UnstableButton>
|
||||
</UnstableEmptyContent>
|
||||
)}
|
||||
</UnstableEmptyHeader>
|
||||
</UnstableEmpty>
|
||||
)}
|
||||
{Boolean(filteredProjectMemberships.length) && (
|
||||
<UnstablePagination
|
||||
count={filteredProjectMemberships.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -87,6 +87,9 @@ export const OrgGenericAuthSection = () => {
|
||||
<SelectItem value={MfaMethod.TOTP} key="mfa-method-totp">
|
||||
Mobile Authenticator
|
||||
</SelectItem>
|
||||
<SelectItem value={MfaMethod.WEBAUTHN} key="mfa-method-webauthn">
|
||||
Passkey (WebAuthn)
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
type Props = {
|
||||
account: TPamAccount;
|
||||
onAccess: (resource: TPamAccount) => void;
|
||||
onUpdate: (resource: TPamAccount) => void;
|
||||
onDelete: (resource: TPamAccount) => void;
|
||||
accountPath?: string;
|
||||
};
|
||||
|
||||
export const PamAccountCard = ({ account, onAccess, accountPath }: Props) => {
|
||||
const { name, description, resource } = account;
|
||||
export const PamAccountCard = ({ account, onAccess, accountPath, onUpdate, onDelete }: Props) => {
|
||||
const { id, name, description, resource } = account;
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
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"
|
||||
>
|
||||
@@ -29,10 +63,58 @@ export const PamAccountCard = ({ account, onAccess, accountPath }: Props) => {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-lg font-medium text-mineshaft-100">{name}</p>
|
||||
<UnstableButton onClick={() => onAccess(account)} size="xs" variant="outline">
|
||||
<LogInIcon />
|
||||
Connect
|
||||
</UnstableButton>
|
||||
<div className="flex items-center gap-2">
|
||||
<UnstableButton onClick={() => onAccess(account)} size="xs" variant="outline">
|
||||
<LogInIcon />
|
||||
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>
|
||||
|
||||
<p
|
||||
@@ -47,6 +129,6 @@ export const PamAccountCard = ({ account, onAccess, accountPath }: Props) => {
|
||||
{resource.name}
|
||||
</Badge>
|
||||
<p className="mt-2 truncate text-sm text-mineshaft-400">{description || "No description"}</p>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
|
||||
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
|
||||
import { SqlAccountFields } from "./shared/SqlAccountFields";
|
||||
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
|
||||
import { RequireMfaField } from "./RequireMfaField";
|
||||
|
||||
type Props = {
|
||||
account?: TMySQLAccount;
|
||||
@@ -21,7 +22,8 @@ const formSchema = genericAccountFieldsSchema.extend({
|
||||
credentials: BaseSqlAccountSchema,
|
||||
// We don't support rotation for now, just feed a false value to
|
||||
// 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>;
|
||||
@@ -49,13 +51,10 @@ export const MySQLAccountForm = ({ account, onSubmit }: Props) => {
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<GenericAccountFields />
|
||||
<SqlAccountFields isUpdate={isUpdate} />
|
||||
<RequireMfaField />
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
@@ -39,7 +39,7 @@ const CreateForm = ({
|
||||
const createPamAccount = useCreatePamAccount();
|
||||
|
||||
const onSubmit = async (
|
||||
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
|
||||
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials" | "requireMfa">
|
||||
) => {
|
||||
const account = await createPamAccount.mutateAsync({
|
||||
...formData,
|
||||
@@ -97,7 +97,7 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
|
||||
const updatePamAccount = useUpdatePamAccount();
|
||||
|
||||
const onSubmit = async (
|
||||
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
|
||||
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials" | "requireMfa">
|
||||
) => {
|
||||
const updatedAccount = await updatePamAccount.mutateAsync({
|
||||
accountId: account.id,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
|
||||
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
|
||||
import { SqlAccountFields } from "./shared/SqlAccountFields";
|
||||
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
|
||||
import { RequireMfaField } from "./RequireMfaField";
|
||||
import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields";
|
||||
|
||||
type Props = {
|
||||
@@ -25,7 +26,8 @@ type Props = {
|
||||
};
|
||||
|
||||
const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({
|
||||
credentials: BaseSqlAccountSchema
|
||||
credentials: BaseSqlAccountSchema,
|
||||
requireMfa: z.boolean().nullable().optional()
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
@@ -77,6 +79,7 @@ export const PostgresAccountForm = ({ account, resourceId, resourceType, onSubmi
|
||||
<GenericAccountFields />
|
||||
<SqlAccountFields isUpdate={isUpdate} />
|
||||
<RotateAccountFields rotationCredentialsConfigured={rotationCredentialsConfigured} />
|
||||
<RequireMfaField />
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { FormControl, Select, SelectItem, Switch, Tooltip } from "@app/component
|
||||
|
||||
export const rotateAccountFieldsSchema = z.object({
|
||||
rotationEnabled: z.boolean(),
|
||||
rotationIntervalSeconds: z.number().nullable().optional()
|
||||
rotationIntervalSeconds: z.number()
|
||||
});
|
||||
|
||||
export const RotateAccountFields = ({
|
||||
@@ -16,15 +16,15 @@ export const RotateAccountFields = ({
|
||||
}) => {
|
||||
const { control, watch } = useFormContext<{
|
||||
rotationEnabled: boolean;
|
||||
rotationIntervalSeconds?: number | null;
|
||||
rotationIntervalSeconds: number;
|
||||
}>();
|
||||
|
||||
const rotationEnabled = watch("rotationEnabled");
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
isDisabled={rotationCredentialsConfigured}
|
||||
content="The resource which owns this account does not have rotation credentials configured."
|
||||
isDisabled={rotationCredentialsConfigured}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
@@ -62,7 +62,7 @@ export const RotateAccountFields = ({
|
||||
className="mb-0"
|
||||
>
|
||||
<Select
|
||||
value={(value || 2592000).toString()}
|
||||
value={value.toString()}
|
||||
onValueChange={(val) => onChange(parseInt(val, 10))}
|
||||
className="w-full border border-mineshaft-500 capitalize"
|
||||
position="popper"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SSHAuthMethod } from "@app/hooks/api/pam/types/ssh-resource";
|
||||
|
||||
import { SshCaSetupSection } from "../../../components/SshCaSetupSection";
|
||||
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
|
||||
import { RequireMfaField } from "./RequireMfaField";
|
||||
|
||||
type Props = {
|
||||
account?: TSSHAccount;
|
||||
@@ -53,7 +54,8 @@ const formSchema = genericAccountFieldsSchema.extend({
|
||||
credentials: BaseSshAccountSchema,
|
||||
// We don't support rotation for now, just feed a false value to
|
||||
// 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>;
|
||||
@@ -225,6 +227,8 @@ export const SshAccountForm = ({ account, resourceId, onSubmit }: Props) => {
|
||||
: {
|
||||
name: "",
|
||||
description: "",
|
||||
requireMfa: false,
|
||||
rotationEnabled: false,
|
||||
credentials: {
|
||||
authMethod: SSHAuthMethod.Password,
|
||||
username: "",
|
||||
@@ -247,6 +251,7 @@ export const SshAccountForm = ({ account, resourceId, onSubmit }: Props) => {
|
||||
>
|
||||
<GenericAccountFields />
|
||||
<SshAccountFields isUpdate={isUpdate} resourceId={effectiveResourceId} />
|
||||
<RequireMfaField />
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
@@ -66,12 +66,9 @@ export const PamAccountRow = ({
|
||||
type: "info"
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => clearTimeout(timer);
|
||||
setTimeout(() => setIsIdCopied.off(), 2000);
|
||||
},
|
||||
[isIdCopied]
|
||||
[setIsIdCopied]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,7 +16,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
@@ -251,13 +250,8 @@ export const PamAccountsTable = ({ projectId }: Props) => {
|
||||
});
|
||||
|
||||
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
|
||||
handlePopUpOpen("requestAccount", { accountPath: fullAccountPath });
|
||||
handlePopUpOpen("requestAccount", { accountPath: fullAccountPath, accountAccessed: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -439,6 +433,8 @@ export const PamAccountsTable = ({ projectId }: Props) => {
|
||||
account={account}
|
||||
accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
|
||||
onAccess={(e: TPamAccount) => accessAccount(e)}
|
||||
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
|
||||
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -560,6 +556,7 @@ export const PamAccountsTable = ({ projectId }: Props) => {
|
||||
isOpen={popUp.requestAccount.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("requestAccount", isOpen)}
|
||||
accountPath={popUp.requestAccount.data?.accountPath}
|
||||
accountAccessed={popUp.requestAccount.data?.accountAccessed}
|
||||
/>
|
||||
<PamDeleteAccountModal
|
||||
isOpen={popUp.deleteAccount.isOpen}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -17,9 +18,11 @@ import {
|
||||
import { useProject } from "@app/context";
|
||||
import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies";
|
||||
import { useCreateApprovalRequest } from "@app/hooks/api/approvalRequests/mutations";
|
||||
import { UnstableAlert, UnstableAlertDescription, UnstableAlertTitle } from "@app/components/v3";
|
||||
|
||||
type Props = {
|
||||
accountPath?: string;
|
||||
accountAccessed?: boolean;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
@@ -45,7 +48,7 @@ const formSchema = z.object({
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const Content = ({ onOpenChange, accountPath }: Props) => {
|
||||
const Content = ({ onOpenChange, accountPath, accountAccessed }: Props) => {
|
||||
const { projectId } = useProject();
|
||||
const { mutateAsync: createApprovalRequest, isPending: isSubmitting } =
|
||||
useCreateApprovalRequest();
|
||||
@@ -94,6 +97,15 @@ const Content = ({ onOpenChange, accountPath }: Props) => {
|
||||
|
||||
return (
|
||||
<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
|
||||
name="accountPath"
|
||||
control={control}
|
||||
|
||||
@@ -34,7 +34,7 @@ export const GroupDetailsSection = ({ groupMembership }: Props) => {
|
||||
|
||||
return (
|
||||
<UnstableCard className="w-full lg:max-w-[24rem]">
|
||||
<UnstableCardHeader>
|
||||
<UnstableCardHeader className="border-b">
|
||||
<UnstableCardTitle>Details</UnstableCardTitle>
|
||||
<UnstableCardDescription>Group details</UnstableCardDescription>
|
||||
</UnstableCardHeader>
|
||||
|
||||
@@ -85,7 +85,7 @@ export const ProjectIdentityAuthenticationSection = ({ identity, refetchIdentity
|
||||
activeLockoutAuthMethods={identity.activeLockoutAuthMethods}
|
||||
/>
|
||||
) : (
|
||||
<UnstableEmpty className="rounded-sm border bg-mineshaft-800/50">
|
||||
<UnstableEmpty className="border">
|
||||
<UnstableEmptyHeader>
|
||||
<UnstableEmptyTitle>
|
||||
This machine identity has no auth methods configured
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ProjectIdentityDetailsSection = ({
|
||||
return (
|
||||
<>
|
||||
<UnstableCard className="w-full lg:max-w-[24rem]">
|
||||
<UnstableCardHeader>
|
||||
<UnstableCardHeader className="border-b">
|
||||
<UnstableCardTitle>Details</UnstableCardTitle>
|
||||
<UnstableCardDescription>Machine identity details</UnstableCardDescription>
|
||||
{!isOrgIdentity && (
|
||||
|
||||
@@ -40,7 +40,7 @@ export const ProjectMemberDetailsSection = ({ membership }: Props) => {
|
||||
|
||||
return (
|
||||
<UnstableCard className="w-full lg:max-w-[24rem]">
|
||||
<UnstableCardHeader>
|
||||
<UnstableCardHeader className="border-b">
|
||||
<UnstableCardTitle>Details</UnstableCardTitle>
|
||||
<UnstableCardDescription>User membership details</UnstableCardDescription>
|
||||
</UnstableCardHeader>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { startRegistration } from "@simplewebauthn/browser";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
EmailServiceSetupModal,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
@@ -28,6 +31,13 @@ import {
|
||||
useGetUserTotpRegistration
|
||||
} from "@app/hooks/api/users/queries";
|
||||
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";
|
||||
|
||||
export const MFASection = () => {
|
||||
@@ -46,7 +56,10 @@ export const MFASection = () => {
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"setUpEmail",
|
||||
"deleteTotpConfig",
|
||||
"downloadRecoveryCodes"
|
||||
"downloadRecoveryCodes",
|
||||
"deleteWebAuthnCredential",
|
||||
"renameWebAuthnCredential",
|
||||
"registerPasskey"
|
||||
] as const);
|
||||
const [shouldShowRecoveryCodes, setShouldShowRecoveryCodes] = useToggle();
|
||||
const { data: totpConfiguration } = useGetUserTotpConfiguration();
|
||||
@@ -60,6 +73,20 @@ export const MFASection = () => {
|
||||
const queryClient = useQueryClient();
|
||||
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
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
@@ -149,6 +284,19 @@ export const MFASection = () => {
|
||||
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);
|
||||
|
||||
// If enabling 2FA with mobile authenticator, verify TOTP first
|
||||
@@ -240,9 +388,97 @@ export const MFASection = () => {
|
||||
if (totpConfiguration?.isVerified) return true;
|
||||
return totpCode.trim().length > 0;
|
||||
}
|
||||
if (formData.selectedMfaMethod === MfaMethod.WEBAUTHN) return true;
|
||||
|
||||
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
|
||||
"Register", you'll be prompted to use your device'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 (
|
||||
<>
|
||||
<form
|
||||
@@ -293,6 +529,7 @@ export const MFASection = () => {
|
||||
>
|
||||
<SelectItem value={MfaMethod.EMAIL}>Email</SelectItem>
|
||||
<SelectItem value={MfaMethod.TOTP}>Mobile Authenticator</SelectItem>
|
||||
<SelectItem value={MfaMethod.WEBAUTHN}>Passkey (WebAuthn)</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
@@ -411,57 +648,162 @@ export const MFASection = () => {
|
||||
</Button>
|
||||
</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>
|
||||
)}
|
||||
</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
|
||||
isOpen={popUp.setUpEmail?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
|
||||
@@ -481,6 +823,19 @@ export const MFASection = () => {
|
||||
recoveryCodes={totpRegistration?.recoveryCodes || []}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ import { Route as authAdminLoginPageRouteImport } from './pages/auth/AdminLoginP
|
||||
import { Route as adminSignUpPageRouteImport } from './pages/admin/SignUpPage/route'
|
||||
import { Route as organizationNoOrgPageRouteImport } from './pages/organization/NoOrgPage/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 authLoginPageRouteImport } from './pages/auth/LoginPage/route'
|
||||
import { Route as redirectsProjectRedirectImport } from './pages/redirects/project-redirect'
|
||||
@@ -549,6 +550,12 @@ const organizationMcpEndpointFinalizePageRouteRoute =
|
||||
getParentRoute: () => middlewaresAuthenticateRoute,
|
||||
} as any)
|
||||
|
||||
const MfaSessionPageRouteRoute = MfaSessionPageRouteImport.update({
|
||||
id: '/mfa-session/$mfaSessionId',
|
||||
path: '/mfa-session/$mfaSessionId',
|
||||
getParentRoute: () => middlewaresAuthenticateRoute,
|
||||
} as any)
|
||||
|
||||
const authSignUpPageRouteRoute = authSignUpPageRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -2478,6 +2485,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof authSignUpPageRouteImport
|
||||
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': {
|
||||
id: '/_authenticate/organization/mcp-endpoint-finalize'
|
||||
path: '/organization/mcp-endpoint-finalize'
|
||||
@@ -5268,6 +5282,7 @@ interface middlewaresAuthenticateRouteChildren {
|
||||
authPasswordSetupPageRouteRoute: typeof authPasswordSetupPageRouteRoute
|
||||
middlewaresInjectOrgDetailsRoute: typeof middlewaresInjectOrgDetailsRouteWithChildren
|
||||
AuthenticatePersonalSettingsRoute: typeof AuthenticatePersonalSettingsRouteWithChildren
|
||||
MfaSessionPageRouteRoute: typeof MfaSessionPageRouteRoute
|
||||
organizationMcpEndpointFinalizePageRouteRoute: typeof organizationMcpEndpointFinalizePageRouteRoute
|
||||
organizationNoOrgPageRouteRoute: typeof organizationNoOrgPageRouteRoute
|
||||
}
|
||||
@@ -5279,6 +5294,7 @@ const middlewaresAuthenticateRouteChildren: middlewaresAuthenticateRouteChildren
|
||||
middlewaresInjectOrgDetailsRouteWithChildren,
|
||||
AuthenticatePersonalSettingsRoute:
|
||||
AuthenticatePersonalSettingsRouteWithChildren,
|
||||
MfaSessionPageRouteRoute: MfaSessionPageRouteRoute,
|
||||
organizationMcpEndpointFinalizePageRouteRoute:
|
||||
organizationMcpEndpointFinalizePageRouteRoute,
|
||||
organizationNoOrgPageRouteRoute: organizationNoOrgPageRouteRoute,
|
||||
@@ -5376,6 +5392,7 @@ export interface FileRoutesByFullPath {
|
||||
'/signup': typeof RestrictLoginSignupSignupRouteWithChildren
|
||||
'/login/': typeof authLoginPageRouteRoute
|
||||
'/signup/': typeof authSignUpPageRouteRoute
|
||||
'/mfa-session/$mfaSessionId': typeof MfaSessionPageRouteRoute
|
||||
'/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
|
||||
'/organizations/none': typeof organizationNoOrgPageRouteRoute
|
||||
'/admin/signup': typeof adminSignUpPageRouteRoute
|
||||
@@ -5631,6 +5648,7 @@ export interface FileRoutesByTo {
|
||||
'/personal-settings': typeof userPersonalSettingsPageRouteRoute
|
||||
'/login': typeof authLoginPageRouteRoute
|
||||
'/signup': typeof authSignUpPageRouteRoute
|
||||
'/mfa-session/$mfaSessionId': typeof MfaSessionPageRouteRoute
|
||||
'/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
|
||||
'/organizations/none': typeof organizationNoOrgPageRouteRoute
|
||||
'/admin/signup': typeof adminSignUpPageRouteRoute
|
||||
@@ -5878,6 +5896,7 @@ export interface FileRoutesById {
|
||||
'/_restrict-login-signup/signup': typeof RestrictLoginSignupSignupRouteWithChildren
|
||||
'/_restrict-login-signup/login/': typeof authLoginPageRouteRoute
|
||||
'/_restrict-login-signup/signup/': typeof authSignUpPageRouteRoute
|
||||
'/_authenticate/mfa-session/$mfaSessionId': typeof MfaSessionPageRouteRoute
|
||||
'/_authenticate/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
|
||||
'/_authenticate/organizations/none': typeof organizationNoOrgPageRouteRoute
|
||||
'/_restrict-login-signup/admin/signup': typeof adminSignUpPageRouteRoute
|
||||
@@ -6147,6 +6166,7 @@ export interface FileRouteTypes {
|
||||
| '/signup'
|
||||
| '/login/'
|
||||
| '/signup/'
|
||||
| '/mfa-session/$mfaSessionId'
|
||||
| '/organization/mcp-endpoint-finalize'
|
||||
| '/organizations/none'
|
||||
| '/admin/signup'
|
||||
@@ -6401,6 +6421,7 @@ export interface FileRouteTypes {
|
||||
| '/personal-settings'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/mfa-session/$mfaSessionId'
|
||||
| '/organization/mcp-endpoint-finalize'
|
||||
| '/organizations/none'
|
||||
| '/admin/signup'
|
||||
@@ -6646,6 +6667,7 @@ export interface FileRouteTypes {
|
||||
| '/_restrict-login-signup/signup'
|
||||
| '/_restrict-login-signup/login/'
|
||||
| '/_restrict-login-signup/signup/'
|
||||
| '/_authenticate/mfa-session/$mfaSessionId'
|
||||
| '/_authenticate/organization/mcp-endpoint-finalize'
|
||||
| '/_authenticate/organizations/none'
|
||||
| '/_restrict-login-signup/admin/signup'
|
||||
@@ -6960,6 +6982,7 @@ export const routeTree = rootRoute
|
||||
"/_authenticate/password-setup",
|
||||
"/_authenticate/_inject-org-details",
|
||||
"/_authenticate/personal-settings",
|
||||
"/_authenticate/mfa-session/$mfaSessionId",
|
||||
"/_authenticate/organization/mcp-endpoint-finalize",
|
||||
"/_authenticate/organizations/none"
|
||||
]
|
||||
@@ -7047,6 +7070,10 @@ export const routeTree = rootRoute
|
||||
"filePath": "auth/SignUpPage/route.tsx",
|
||||
"parent": "/_restrict-login-signup/signup"
|
||||
},
|
||||
"/_authenticate/mfa-session/$mfaSessionId": {
|
||||
"filePath": "MfaSessionPage/route.tsx",
|
||||
"parent": "/_authenticate"
|
||||
},
|
||||
"/_authenticate/organization/mcp-endpoint-finalize": {
|
||||
"filePath": "organization/McpEndpointFinalizePage/route.tsx",
|
||||
"parent": "/_authenticate"
|
||||
|
||||
@@ -443,6 +443,7 @@ export const routes = rootRoute("root.tsx", [
|
||||
]),
|
||||
middleware("authenticate.tsx", [
|
||||
route("/password-setup", "auth/PasswordSetupPage/route.tsx"),
|
||||
route("/mfa-session/$mfaSessionId", "MfaSessionPage/route.tsx"),
|
||||
route("/personal-settings", [
|
||||
layout("user/layout.tsx", [index("user/PersonalSettingsPage/route.tsx")])
|
||||
]),
|
||||
|
||||
Reference in New Issue
Block a user