mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-22 19:55:37 +02:00
Merge branch 'dev' into clients-pops-dev
This commit is contained in:
commit
3dc79da2fa
55 changed files with 5261 additions and 4194 deletions
5
.github/dependabot.yml
vendored
5
.github/dependabot.yml
vendored
|
@ -33,3 +33,8 @@ updates:
|
||||||
minor-updates:
|
minor-updates:
|
||||||
update-types:
|
update-types:
|
||||||
- "minor"
|
- "minor"
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
8
.github/workflows/cicd.yml
vendored
8
.github/workflows/cicd.yml
vendored
|
@ -12,13 +12,13 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
|
@ -28,7 +28,7 @@ jobs:
|
||||||
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: 1.23.0
|
go-version: 1.23.0
|
||||||
|
|
||||||
|
|
55
.github/workflows/test.yml
vendored
Normal file
55
.github/workflows/test.yml
vendored
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
name: Run Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Copy config file
|
||||||
|
run: cp config/config.example.yml config/config.yml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Create database index.ts
|
||||||
|
run: echo 'export * from "./sqlite";' > server/db/index.ts
|
||||||
|
|
||||||
|
- name: Generate database migrations
|
||||||
|
run: npm run db:sqlite:generate
|
||||||
|
|
||||||
|
- name: Apply database migrations
|
||||||
|
run: npm run db:sqlite:push
|
||||||
|
|
||||||
|
- name: Start app in background
|
||||||
|
run: nohup npm run dev &
|
||||||
|
|
||||||
|
- name: Wait for app availability
|
||||||
|
run: |
|
||||||
|
for i in {1..5}; do
|
||||||
|
if curl --silent --fail http://localhost:3002/auth/login; then
|
||||||
|
echo "App is up"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Waiting for the app... attempt $i"
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "App failed to start"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Build Docker image sqlite
|
||||||
|
run: make build
|
||||||
|
|
||||||
|
- name: Build Docker image pg
|
||||||
|
run: make build-pg
|
|
@ -3,8 +3,8 @@ FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# COPY package.json package-lock.json ./
|
# COPY package.json package-lock.json ./
|
||||||
COPY package.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
@ -23,8 +23,8 @@ WORKDIR /app
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
# COPY package.json package-lock.json ./
|
# COPY package.json package-lock.json ./
|
||||||
COPY package.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --only=production && npm cache clean --force
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
|
@ -3,8 +3,8 @@ FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# COPY package.json package-lock.json ./
|
# COPY package.json package-lock.json ./
|
||||||
COPY package.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
@ -23,8 +23,8 @@ WORKDIR /app
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
# COPY package.json package-lock.json ./
|
# COPY package.json package-lock.json ./
|
||||||
COPY package.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --only=production && npm cache clean --force
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
9
Makefile
9
Makefile
|
@ -1,6 +1,8 @@
|
||||||
|
.PHONY: build build-pg build-release build-arm build-x86 test clean
|
||||||
|
|
||||||
build-release:
|
build-release:
|
||||||
@if [ -z "$(tag)" ]; then \
|
@if [ -z "$(tag)" ]; then \
|
||||||
echo "Error: tag is required. Usage: make build-all tag=<tag>"; \
|
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:latest -f Dockerfile --push .
|
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:latest -f Dockerfile --push .
|
||||||
|
@ -15,7 +17,10 @@ build-x86:
|
||||||
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
|
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker build -t fosrl/pangolin:latest .
|
docker build -t fosrl/pangolin:latest -f Dockerfile .
|
||||||
|
|
||||||
|
build-pg:
|
||||||
|
docker build -t fosrl/pangolin:postgresql-latest -f Dockerfile.pg .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest
|
docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest
|
||||||
|
|
|
@ -1132,5 +1132,23 @@
|
||||||
"initialSetupTitle": "Initial Einrichtung des Servers",
|
"initialSetupTitle": "Initial Einrichtung des Servers",
|
||||||
"initialSetupDescription": "Erstellen Sie das initiale Server-Admin-Konto. Es kann nur einen Server-Admin geben. Sie können diese Anmeldedaten später immer ändern.",
|
"initialSetupDescription": "Erstellen Sie das initiale Server-Admin-Konto. Es kann nur einen Server-Admin geben. Sie können diese Anmeldedaten später immer ändern.",
|
||||||
"createAdminAccount": "Admin-Konto erstellen",
|
"createAdminAccount": "Admin-Konto erstellen",
|
||||||
"setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten."
|
"setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.",
|
||||||
|
"securityKeyManage": "Sicherheitsschlüssel verwalten",
|
||||||
|
"securityKeyDescription": "Sicherheitsschlüssel für passwortlose Authentifizierung hinzufügen oder entfernen",
|
||||||
|
"securityKeyRegister": "Neuen Sicherheitsschlüssel registrieren",
|
||||||
|
"securityKeyList": "Ihre Sicherheitsschlüssel",
|
||||||
|
"securityKeyNone": "Noch keine Sicherheitsschlüssel registriert",
|
||||||
|
"securityKeyNameRequired": "Name ist erforderlich",
|
||||||
|
"securityKeyRemove": "Entfernen",
|
||||||
|
"securityKeyLastUsed": "Zuletzt verwendet: {date}",
|
||||||
|
"securityKeyNameLabel": "Name",
|
||||||
|
"securityKeyNamePlaceholder": "Geben Sie einen Namen für diesen Sicherheitsschlüssel ein",
|
||||||
|
"securityKeyRegisterSuccess": "Sicherheitsschlüssel erfolgreich registriert",
|
||||||
|
"securityKeyRegisterError": "Fehler beim Registrieren des Sicherheitsschlüssels",
|
||||||
|
"securityKeyRemoveSuccess": "Sicherheitsschlüssel erfolgreich entfernt",
|
||||||
|
"securityKeyRemoveError": "Fehler beim Entfernen des Sicherheitsschlüssels",
|
||||||
|
"securityKeyLoadError": "Fehler beim Laden der Sicherheitsschlüssel",
|
||||||
|
"securityKeyLogin": "Mit Sicherheitsschlüssel anmelden",
|
||||||
|
"securityKeyAuthError": "Fehler bei der Authentifizierung mit Sicherheitsschlüssel",
|
||||||
|
"securityKeyRecommendation": "Erwägen Sie die Registrierung eines weiteren Sicherheitsschlüssels auf einem anderen Gerät, um sicherzustellen, dass Sie sich nicht aus Ihrem Konto aussperren."
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
"welcome": "Welcome!",
|
"welcome": "Welcome!",
|
||||||
"welcomeTo": "Welcome to",
|
"welcomeTo": "Welcome to",
|
||||||
"componentsCreateOrg": "Create an Organization",
|
"componentsCreateOrg": "Create an Organization",
|
||||||
"componentsMember": "You're a member of {count, plural, =0 {no organization} =1 {one organization} other {# organizations}}.",
|
"componentsMember": "You're a member of {count, plural, =0 {no organization} one {one organization} other {# organizations}}.",
|
||||||
"componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.",
|
"componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.",
|
"componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.",
|
||||||
|
@ -251,7 +251,7 @@
|
||||||
"weeks": "Weeks",
|
"weeks": "Weeks",
|
||||||
"months": "Months",
|
"months": "Months",
|
||||||
"years": "Years",
|
"years": "Years",
|
||||||
"day": "{count, plural, =1 {# day} other {# days}}",
|
"day": "{count, plural, one {# day} other {# days}}",
|
||||||
"apiKeysTitle": "API Key Information",
|
"apiKeysTitle": "API Key Information",
|
||||||
"apiKeysConfirmCopy2": "You must confirm that you have copied the API key.",
|
"apiKeysConfirmCopy2": "You must confirm that you have copied the API key.",
|
||||||
"apiKeysErrorCreate": "Error creating API key",
|
"apiKeysErrorCreate": "Error creating API key",
|
||||||
|
@ -349,7 +349,7 @@
|
||||||
"licensePurchase": "Purchase License",
|
"licensePurchase": "Purchase License",
|
||||||
"licensePurchaseSites": "Purchase Additional Sites",
|
"licensePurchaseSites": "Purchase Additional Sites",
|
||||||
"licenseSitesUsedMax": "{usedSites} of {maxSites} sites used",
|
"licenseSitesUsedMax": "{usedSites} of {maxSites} sites used",
|
||||||
"licenseSitesUsed": "{count, plural, =0 {# sites} =1 {# site} other {# sites}} in system.",
|
"licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} in system.",
|
||||||
"licensePurchaseDescription": "Choose how many sites you want to {selectedMode, select, license {purchase a license for. You can always add more sites later.} other {add to your existing license.}}",
|
"licensePurchaseDescription": "Choose how many sites you want to {selectedMode, select, license {purchase a license for. You can always add more sites later.} other {add to your existing license.}}",
|
||||||
"licenseFee": "License fee",
|
"licenseFee": "License fee",
|
||||||
"licensePriceSite": "Price per site",
|
"licensePriceSite": "Price per site",
|
||||||
|
@ -438,7 +438,7 @@
|
||||||
"accessRoleSelect": "Select role",
|
"accessRoleSelect": "Select role",
|
||||||
"inviteEmailSentDescription": "An email has been sent to the user with the access link below. They must access the link to accept the invitation.",
|
"inviteEmailSentDescription": "An email has been sent to the user with the access link below. They must access the link to accept the invitation.",
|
||||||
"inviteSentDescription": "The user has been invited. They must access the link below to accept the invitation.",
|
"inviteSentDescription": "The user has been invited. They must access the link below to accept the invitation.",
|
||||||
"inviteExpiresIn": "The invite will expire in {days, plural, =1 {# day} other {# days}}.",
|
"inviteExpiresIn": "The invite will expire in {days, plural, one {# day} other {# days}}.",
|
||||||
"idpTitle": "Identity Provider",
|
"idpTitle": "Identity Provider",
|
||||||
"idpSelect": "Select the identity provider for the external user",
|
"idpSelect": "Select the identity provider for the external user",
|
||||||
"idpNotConfigured": "No identity providers are configured. Please configure an identity provider before creating external users.",
|
"idpNotConfigured": "No identity providers are configured. Please configure an identity provider before creating external users.",
|
||||||
|
@ -960,6 +960,8 @@
|
||||||
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
||||||
"actionGetOrg": "Get Organization",
|
"actionGetOrg": "Get Organization",
|
||||||
"actionUpdateOrg": "Update Organization",
|
"actionUpdateOrg": "Update Organization",
|
||||||
|
"actionUpdateUser": "Update User",
|
||||||
|
"actionGetUser": "Get User",
|
||||||
"actionGetOrgUser": "Get Organization User",
|
"actionGetOrgUser": "Get Organization User",
|
||||||
"actionListOrgDomains": "List Organization Domains",
|
"actionListOrgDomains": "List Organization Domains",
|
||||||
"actionCreateSite": "Create Site",
|
"actionCreateSite": "Create Site",
|
||||||
|
@ -1106,7 +1108,7 @@
|
||||||
"containerNetworks": "Networks",
|
"containerNetworks": "Networks",
|
||||||
"containerHostnameIp": "Hostname/IP",
|
"containerHostnameIp": "Hostname/IP",
|
||||||
"containerLabels": "Labels",
|
"containerLabels": "Labels",
|
||||||
"containerLabelsCount": "{count} label{s,plural,one{} other{s}}",
|
"containerLabelsCount": "{count, plural, one {# label} other {# labels}}",
|
||||||
"containerLabelsTitle": "Container Labels",
|
"containerLabelsTitle": "Container Labels",
|
||||||
"containerLabelEmpty": "<empty>",
|
"containerLabelEmpty": "<empty>",
|
||||||
"containerPorts": "Ports",
|
"containerPorts": "Ports",
|
||||||
|
@ -1118,7 +1120,7 @@
|
||||||
"showStoppedContainers": "Show stopped containers",
|
"showStoppedContainers": "Show stopped containers",
|
||||||
"noContainersFound": "No containers found. Make sure Docker containers are running.",
|
"noContainersFound": "No containers found. Make sure Docker containers are running.",
|
||||||
"searchContainersPlaceholder": "Search across {count} containers...",
|
"searchContainersPlaceholder": "Search across {count} containers...",
|
||||||
"searchResultsCount": "{count} result{s,plural,one{} other{s}}",
|
"searchResultsCount": "{count, plural, one {# result} other {# results}}",
|
||||||
"filters": "Filters",
|
"filters": "Filters",
|
||||||
"filterOptions": "Filter Options",
|
"filterOptions": "Filter Options",
|
||||||
"filterPorts": "Ports",
|
"filterPorts": "Ports",
|
||||||
|
@ -1214,5 +1216,44 @@
|
||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"createNewOrgDescription": "Create a new organization",
|
"createNewOrgDescription": "Create a new organization",
|
||||||
"organization": "Organization",
|
"organization": "Organization",
|
||||||
"port": "Port"
|
"port": "Port",
|
||||||
|
"securityKeyManage": "Manage Security Keys",
|
||||||
|
"securityKeyDescription": "Add or remove security keys for passwordless authentication",
|
||||||
|
"securityKeyRegister": "Register New Security Key",
|
||||||
|
"securityKeyList": "Your Security Keys",
|
||||||
|
"securityKeyNone": "No security keys registered yet",
|
||||||
|
"securityKeyNameRequired": "Name is required",
|
||||||
|
"securityKeyRemove": "Remove",
|
||||||
|
"securityKeyLastUsed": "Last used: {date}",
|
||||||
|
"securityKeyNameLabel": "Security Key Name",
|
||||||
|
"securityKeyRegisterSuccess": "Security key registered successfully",
|
||||||
|
"securityKeyRegisterError": "Failed to register security key",
|
||||||
|
"securityKeyRemoveSuccess": "Security key removed successfully",
|
||||||
|
"securityKeyRemoveError": "Failed to remove security key",
|
||||||
|
"securityKeyLoadError": "Failed to load security keys",
|
||||||
|
"securityKeyLogin": "Sign in with security key",
|
||||||
|
"securityKeyAuthError": "Failed to authenticate with security key",
|
||||||
|
"securityKeyRecommendation": "Register a backup security key on another device to ensure you always have access to your account.",
|
||||||
|
"registering": "Registering...",
|
||||||
|
"securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.",
|
||||||
|
"securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.",
|
||||||
|
"securityKeyPermissionDenied": "Please allow access to your security key to continue signing in.",
|
||||||
|
"securityKeyRemovedTooQuickly": "Please keep your security key connected until the sign-in process completes.",
|
||||||
|
"securityKeyNotSupported": "Your security key may not be compatible. Please try a different security key.",
|
||||||
|
"securityKeyUnknownError": "There was a problem using your security key. Please try again.",
|
||||||
|
"twoFactorRequired": "Two-factor authentication is required to register a security key.",
|
||||||
|
"twoFactor": "Two-Factor Authentication",
|
||||||
|
"adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.",
|
||||||
|
"continueToApplication": "Continue to Application",
|
||||||
|
"securityKeyAdd": "Add Security Key",
|
||||||
|
"securityKeyRegisterTitle": "Register New Security Key",
|
||||||
|
"securityKeyRegisterDescription": "Connect your security key and enter a name to identify it",
|
||||||
|
"securityKeyTwoFactorRequired": "Two-Factor Authentication Required",
|
||||||
|
"securityKeyTwoFactorDescription": "Please enter your two-factor authentication code to register the security key",
|
||||||
|
"securityKeyTwoFactorRemoveDescription": "Please enter your two-factor authentication code to remove the security key",
|
||||||
|
"securityKeyTwoFactorCode": "Two-Factor Code",
|
||||||
|
"securityKeyRemoveTitle": "Remove Security Key",
|
||||||
|
"securityKeyRemoveDescription": "Enter your password to remove the security key \"{name}\"",
|
||||||
|
"securityKeyNoKeysRegistered": "No security keys registered",
|
||||||
|
"securityKeyNoKeysDescription": "Add a security key to enhance your account security"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1132,5 +1132,23 @@
|
||||||
"initialSetupTitle": "Configuración inicial del servidor",
|
"initialSetupTitle": "Configuración inicial del servidor",
|
||||||
"initialSetupDescription": "Cree la cuenta de administrador del servidor inicial. Solo puede existir un administrador del servidor. Siempre puede cambiar estas credenciales más tarde.",
|
"initialSetupDescription": "Cree la cuenta de administrador del servidor inicial. Solo puede existir un administrador del servidor. Siempre puede cambiar estas credenciales más tarde.",
|
||||||
"createAdminAccount": "Crear cuenta de administrador",
|
"createAdminAccount": "Crear cuenta de administrador",
|
||||||
"setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor."
|
"setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.",
|
||||||
|
"securityKeyManage": "Gestionar llaves de seguridad",
|
||||||
|
"securityKeyDescription": "Agregar o eliminar llaves de seguridad para autenticación sin contraseña",
|
||||||
|
"securityKeyRegister": "Registrar nueva llave de seguridad",
|
||||||
|
"securityKeyList": "Tus llaves de seguridad",
|
||||||
|
"securityKeyNone": "No hay llaves de seguridad registradas",
|
||||||
|
"securityKeyNameRequired": "El nombre es requerido",
|
||||||
|
"securityKeyRemove": "Eliminar",
|
||||||
|
"securityKeyLastUsed": "Último uso: {date}",
|
||||||
|
"securityKeyNameLabel": "Nombre",
|
||||||
|
"securityKeyNamePlaceholder": "Ingrese un nombre para esta llave de seguridad",
|
||||||
|
"securityKeyRegisterSuccess": "Llave de seguridad registrada exitosamente",
|
||||||
|
"securityKeyRegisterError": "Error al registrar la llave de seguridad",
|
||||||
|
"securityKeyRemoveSuccess": "Llave de seguridad eliminada exitosamente",
|
||||||
|
"securityKeyRemoveError": "Error al eliminar la llave de seguridad",
|
||||||
|
"securityKeyLoadError": "Error al cargar las llaves de seguridad",
|
||||||
|
"securityKeyLogin": "Iniciar sesión con llave de seguridad",
|
||||||
|
"securityKeyAuthError": "Error al autenticar con llave de seguridad",
|
||||||
|
"securityKeyRecommendation": "Considere registrar otra llave de seguridad en un dispositivo diferente para asegurarse de no quedar bloqueado de su cuenta."
|
||||||
}
|
}
|
||||||
|
|
|
@ -184,7 +184,7 @@
|
||||||
"cancel": "Abandonner",
|
"cancel": "Abandonner",
|
||||||
"resourceConfig": "Snippets de configuration",
|
"resourceConfig": "Snippets de configuration",
|
||||||
"resourceConfigDescription": "Copiez et collez ces modules de configuration pour configurer votre ressource TCP/UDP",
|
"resourceConfigDescription": "Copiez et collez ces modules de configuration pour configurer votre ressource TCP/UDP",
|
||||||
"resourceAddEntrypoints": "Traefik: Ajouter des points d’entrée",
|
"resourceAddEntrypoints": "Traefik: Ajouter des points d'entrée",
|
||||||
"resourceExposePorts": "Gerbil: Exposer des ports dans Docker Compose",
|
"resourceExposePorts": "Gerbil: Exposer des ports dans Docker Compose",
|
||||||
"resourceLearnRaw": "Apprenez à configurer les ressources TCP/UDP",
|
"resourceLearnRaw": "Apprenez à configurer les ressources TCP/UDP",
|
||||||
"resourceBack": "Retour aux ressources",
|
"resourceBack": "Retour aux ressources",
|
||||||
|
@ -1132,5 +1132,23 @@
|
||||||
"initialSetupTitle": "Configuration initiale du serveur",
|
"initialSetupTitle": "Configuration initiale du serveur",
|
||||||
"initialSetupDescription": "Créer le compte administrateur du serveur initial. Un seul administrateur serveur peut exister. Vous pouvez toujours changer ces informations d'identification plus tard.",
|
"initialSetupDescription": "Créer le compte administrateur du serveur initial. Un seul administrateur serveur peut exister. Vous pouvez toujours changer ces informations d'identification plus tard.",
|
||||||
"createAdminAccount": "Créer un compte administrateur",
|
"createAdminAccount": "Créer un compte administrateur",
|
||||||
"setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur."
|
"setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.",
|
||||||
|
"securityKeyManage": "Gérer les clés de sécurité",
|
||||||
|
"securityKeyDescription": "Ajouter ou supprimer des clés de sécurité pour l'authentification sans mot de passe",
|
||||||
|
"securityKeyRegister": "Enregistrer une nouvelle clé de sécurité",
|
||||||
|
"securityKeyList": "Vos clés de sécurité",
|
||||||
|
"securityKeyNone": "Aucune clé de sécurité enregistrée",
|
||||||
|
"securityKeyNameRequired": "Le nom est requis",
|
||||||
|
"securityKeyRemove": "Supprimer",
|
||||||
|
"securityKeyLastUsed": "Dernière utilisation : {date}",
|
||||||
|
"securityKeyNameLabel": "Nom",
|
||||||
|
"securityKeyNamePlaceholder": "Entrez un nom pour cette clé de sécurité",
|
||||||
|
"securityKeyRegisterSuccess": "Clé de sécurité enregistrée avec succès",
|
||||||
|
"securityKeyRegisterError": "Échec de l'enregistrement de la clé de sécurité",
|
||||||
|
"securityKeyRemoveSuccess": "Clé de sécurité supprimée avec succès",
|
||||||
|
"securityKeyRemoveError": "Échec de la suppression de la clé de sécurité",
|
||||||
|
"securityKeyLoadError": "Échec du chargement des clés de sécurité",
|
||||||
|
"securityKeyLogin": "Se connecter avec une clé de sécurité",
|
||||||
|
"securityKeyAuthError": "Échec de l'authentification avec la clé de sécurité",
|
||||||
|
"securityKeyRecommendation": "Envisagez d'enregistrer une autre clé de sécurité sur un appareil différent pour vous assurer de ne pas être bloqué de votre compte."
|
||||||
}
|
}
|
||||||
|
|
|
@ -1132,5 +1132,23 @@
|
||||||
"initialSetupTitle": "Impostazione Iniziale del Server",
|
"initialSetupTitle": "Impostazione Iniziale del Server",
|
||||||
"initialSetupDescription": "Crea l'account amministratore del server iniziale. Può esistere solo un amministratore del server. È sempre possibile modificare queste credenziali in seguito.",
|
"initialSetupDescription": "Crea l'account amministratore del server iniziale. Può esistere solo un amministratore del server. È sempre possibile modificare queste credenziali in seguito.",
|
||||||
"createAdminAccount": "Crea Account Admin",
|
"createAdminAccount": "Crea Account Admin",
|
||||||
"setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server."
|
"setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.",
|
||||||
|
"securityKeyManage": "Gestisci chiavi di sicurezza",
|
||||||
|
"securityKeyDescription": "Aggiungi o rimuovi chiavi di sicurezza per l'autenticazione senza password",
|
||||||
|
"securityKeyRegister": "Registra nuova chiave di sicurezza",
|
||||||
|
"securityKeyList": "Le tue chiavi di sicurezza",
|
||||||
|
"securityKeyNone": "Nessuna chiave di sicurezza registrata",
|
||||||
|
"securityKeyNameRequired": "Il nome è obbligatorio",
|
||||||
|
"securityKeyRemove": "Rimuovi",
|
||||||
|
"securityKeyLastUsed": "Ultimo utilizzo: {date}",
|
||||||
|
"securityKeyNameLabel": "Nome",
|
||||||
|
"securityKeyNamePlaceholder": "Inserisci un nome per questa chiave di sicurezza",
|
||||||
|
"securityKeyRegisterSuccess": "Chiave di sicurezza registrata con successo",
|
||||||
|
"securityKeyRegisterError": "Errore durante la registrazione della chiave di sicurezza",
|
||||||
|
"securityKeyRemoveSuccess": "Chiave di sicurezza rimossa con successo",
|
||||||
|
"securityKeyRemoveError": "Errore durante la rimozione della chiave di sicurezza",
|
||||||
|
"securityKeyLoadError": "Errore durante il caricamento delle chiavi di sicurezza",
|
||||||
|
"securityKeyLogin": "Accedi con chiave di sicurezza",
|
||||||
|
"securityKeyAuthError": "Errore durante l'autenticazione con chiave di sicurezza",
|
||||||
|
"securityKeyRecommendation": "Considera di registrare un'altra chiave di sicurezza su un dispositivo diverso per assicurarti di non rimanere bloccato fuori dal tuo account."
|
||||||
}
|
}
|
||||||
|
|
1136
messages/ko-KR.json
Normal file
1136
messages/ko-KR.json
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1132,5 +1132,23 @@
|
||||||
"initialSetupTitle": "Initiële serverconfiguratie",
|
"initialSetupTitle": "Initiële serverconfiguratie",
|
||||||
"initialSetupDescription": "Maak het eerste serverbeheeraccount aan. Er kan slechts één serverbeheerder bestaan. U kunt deze inloggegevens later altijd wijzigen.",
|
"initialSetupDescription": "Maak het eerste serverbeheeraccount aan. Er kan slechts één serverbeheerder bestaan. U kunt deze inloggegevens later altijd wijzigen.",
|
||||||
"createAdminAccount": "Maak een beheeraccount aan",
|
"createAdminAccount": "Maak een beheeraccount aan",
|
||||||
"setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount."
|
"setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.",
|
||||||
|
"securityKeyManage": "Beveiligingssleutels beheren",
|
||||||
|
"securityKeyDescription": "Voeg beveiligingssleutels toe of verwijder ze voor wachtwoordloze authenticatie",
|
||||||
|
"securityKeyRegister": "Nieuwe beveiligingssleutel registreren",
|
||||||
|
"securityKeyList": "Uw beveiligingssleutels",
|
||||||
|
"securityKeyNone": "Nog geen beveiligingssleutels geregistreerd",
|
||||||
|
"securityKeyNameRequired": "Naam is verplicht",
|
||||||
|
"securityKeyRemove": "Verwijderen",
|
||||||
|
"securityKeyLastUsed": "Laatst gebruikt: {date}",
|
||||||
|
"securityKeyNameLabel": "Naam",
|
||||||
|
"securityKeyNamePlaceholder": "Voer een naam in voor deze beveiligingssleutel",
|
||||||
|
"securityKeyRegisterSuccess": "Beveiligingssleutel succesvol geregistreerd",
|
||||||
|
"securityKeyRegisterError": "Fout bij registreren van beveiligingssleutel",
|
||||||
|
"securityKeyRemoveSuccess": "Beveiligingssleutel succesvol verwijderd",
|
||||||
|
"securityKeyRemoveError": "Fout bij verwijderen van beveiligingssleutel",
|
||||||
|
"securityKeyLoadError": "Fout bij laden van beveiligingssleutels",
|
||||||
|
"securityKeyLogin": "Inloggen met beveiligingssleutel",
|
||||||
|
"securityKeyAuthError": "Fout bij authenticatie met beveiligingssleutel",
|
||||||
|
"securityKeyRecommendation": "Overweeg om een andere beveiligingssleutel te registreren op een ander apparaat om ervoor te zorgen dat u niet buitengesloten raakt van uw account."
|
||||||
}
|
}
|
||||||
|
|
|
@ -1132,5 +1132,23 @@
|
||||||
"initialSetupTitle": "Wstępna konfiguracja serwera",
|
"initialSetupTitle": "Wstępna konfiguracja serwera",
|
||||||
"initialSetupDescription": "Utwórz początkowe konto administratora serwera. Może istnieć tylko jeden administrator serwera. Zawsze można zmienić te dane uwierzytelniające.",
|
"initialSetupDescription": "Utwórz początkowe konto administratora serwera. Może istnieć tylko jeden administrator serwera. Zawsze można zmienić te dane uwierzytelniające.",
|
||||||
"createAdminAccount": "Utwórz konto administratora",
|
"createAdminAccount": "Utwórz konto administratora",
|
||||||
"setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera."
|
"setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.",
|
||||||
|
"securityKeyManage": "Zarządzaj kluczami bezpieczeństwa",
|
||||||
|
"securityKeyDescription": "Dodaj lub usuń klucze bezpieczeństwa do uwierzytelniania bez hasła",
|
||||||
|
"securityKeyRegister": "Zarejestruj nowy klucz bezpieczeństwa",
|
||||||
|
"securityKeyList": "Twoje klucze bezpieczeństwa",
|
||||||
|
"securityKeyNone": "Brak zarejestrowanych kluczy bezpieczeństwa",
|
||||||
|
"securityKeyNameRequired": "Nazwa jest wymagana",
|
||||||
|
"securityKeyRemove": "Usuń",
|
||||||
|
"securityKeyLastUsed": "Ostatnio używany: {date}",
|
||||||
|
"securityKeyNameLabel": "Nazwa",
|
||||||
|
"securityKeyNamePlaceholder": "Wprowadź nazwę dla tego klucza bezpieczeństwa",
|
||||||
|
"securityKeyRegisterSuccess": "Klucz bezpieczeństwa został pomyślnie zarejestrowany",
|
||||||
|
"securityKeyRegisterError": "Błąd podczas rejestracji klucza bezpieczeństwa",
|
||||||
|
"securityKeyRemoveSuccess": "Klucz bezpieczeństwa został pomyślnie usunięty",
|
||||||
|
"securityKeyRemoveError": "Błąd podczas usuwania klucza bezpieczeństwa",
|
||||||
|
"securityKeyLoadError": "Błąd podczas ładowania kluczy bezpieczeństwa",
|
||||||
|
"securityKeyLogin": "Zaloguj się kluczem bezpieczeństwa",
|
||||||
|
"securityKeyAuthError": "Błąd podczas uwierzytelniania kluczem bezpieczeństwa",
|
||||||
|
"securityKeyRecommendation": "Rozważ zarejestrowanie innego klucza bezpieczeństwa na innym urządzeniu, aby upewnić się, że nie zostaniesz zablokowany z dostępu do swojego konta."
|
||||||
}
|
}
|
||||||
|
|
|
@ -1132,5 +1132,23 @@
|
||||||
"initialSetupTitle": "Configuração Inicial do Servidor",
|
"initialSetupTitle": "Configuração Inicial do Servidor",
|
||||||
"initialSetupDescription": "Crie a conta de administrador inicial do servidor. Apenas um administrador do servidor pode existir. Você sempre pode alterar essas credenciais posteriormente.",
|
"initialSetupDescription": "Crie a conta de administrador inicial do servidor. Apenas um administrador do servidor pode existir. Você sempre pode alterar essas credenciais posteriormente.",
|
||||||
"createAdminAccount": "Criar Conta de Administrador",
|
"createAdminAccount": "Criar Conta de Administrador",
|
||||||
"setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor."
|
"setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.",
|
||||||
|
"securityKeyManage": "Gerenciar chaves de segurança",
|
||||||
|
"securityKeyDescription": "Adicionar ou remover chaves de segurança para autenticação sem senha",
|
||||||
|
"securityKeyRegister": "Registrar nova chave de segurança",
|
||||||
|
"securityKeyList": "Suas chaves de segurança",
|
||||||
|
"securityKeyNone": "Nenhuma chave de segurança registrada",
|
||||||
|
"securityKeyNameRequired": "Nome é obrigatório",
|
||||||
|
"securityKeyRemove": "Remover",
|
||||||
|
"securityKeyLastUsed": "Último uso: {date}",
|
||||||
|
"securityKeyNameLabel": "Nome",
|
||||||
|
"securityKeyNamePlaceholder": "Digite um nome para esta chave de segurança",
|
||||||
|
"securityKeyRegisterSuccess": "Chave de segurança registrada com sucesso",
|
||||||
|
"securityKeyRegisterError": "Erro ao registrar chave de segurança",
|
||||||
|
"securityKeyRemoveSuccess": "Chave de segurança removida com sucesso",
|
||||||
|
"securityKeyRemoveError": "Erro ao remover chave de segurança",
|
||||||
|
"securityKeyLoadError": "Erro ao carregar chaves de segurança",
|
||||||
|
"securityKeyLogin": "Entrar com chave de segurança",
|
||||||
|
"securityKeyAuthError": "Erro ao autenticar com chave de segurança",
|
||||||
|
"securityKeyRecommendation": "Considere registrar outra chave de segurança em um dispositivo diferente para garantir que você não fique bloqueado da sua conta."
|
||||||
}
|
}
|
||||||
|
|
|
@ -1132,5 +1132,23 @@
|
||||||
"initialSetupTitle": "İlk Sunucu Kurulumu",
|
"initialSetupTitle": "İlk Sunucu Kurulumu",
|
||||||
"initialSetupDescription": "İlk sunucu yönetici hesabını oluşturun. Yalnızca bir sunucu yöneticisi olabilir. Bu kimlik bilgilerini daha sonra her zaman değiştirebilirsiniz.",
|
"initialSetupDescription": "İlk sunucu yönetici hesabını oluşturun. Yalnızca bir sunucu yöneticisi olabilir. Bu kimlik bilgilerini daha sonra her zaman değiştirebilirsiniz.",
|
||||||
"createAdminAccount": "Yönetici Hesabı Oluştur",
|
"createAdminAccount": "Yönetici Hesabı Oluştur",
|
||||||
"setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu."
|
"setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.",
|
||||||
|
"securityKeyManage": "Güvenlik Anahtarlarını Yönet",
|
||||||
|
"securityKeyDescription": "Şifresiz kimlik doğrulama için güvenlik anahtarları ekleyin veya kaldırın",
|
||||||
|
"securityKeyRegister": "Yeni Güvenlik Anahtarı Kaydet",
|
||||||
|
"securityKeyList": "Güvenlik Anahtarlarınız",
|
||||||
|
"securityKeyNone": "Henüz kayıtlı güvenlik anahtarı yok",
|
||||||
|
"securityKeyNameRequired": "İsim gerekli",
|
||||||
|
"securityKeyRemove": "Kaldır",
|
||||||
|
"securityKeyLastUsed": "Son kullanım: {date}",
|
||||||
|
"securityKeyNameLabel": "İsim",
|
||||||
|
"securityKeyNamePlaceholder": "Bu güvenlik anahtarı için bir isim girin",
|
||||||
|
"securityKeyRegisterSuccess": "Güvenlik anahtarı başarıyla kaydedildi",
|
||||||
|
"securityKeyRegisterError": "Güvenlik anahtarı kaydedilirken hata oluştu",
|
||||||
|
"securityKeyRemoveSuccess": "Güvenlik anahtarı başarıyla kaldırıldı",
|
||||||
|
"securityKeyRemoveError": "Güvenlik anahtarı kaldırılırken hata oluştu",
|
||||||
|
"securityKeyLoadError": "Güvenlik anahtarları yüklenirken hata oluştu",
|
||||||
|
"securityKeyLogin": "Güvenlik anahtarı ile giriş yap",
|
||||||
|
"securityKeyAuthError": "Güvenlik anahtarı ile kimlik doğrulama başarısız oldu",
|
||||||
|
"securityKeyRecommendation": "Hesabınızdan kilitlenmediğinizden emin olmak için farklı bir cihazda başka bir güvenlik anahtarı kaydetmeyi düşünün."
|
||||||
}
|
}
|
||||||
|
|
|
@ -212,7 +212,7 @@
|
||||||
"orgDeleteConfirm": "确认删除组织",
|
"orgDeleteConfirm": "确认删除组织",
|
||||||
"orgMessageRemove": "此操作不可逆,这将删除所有相关数据。",
|
"orgMessageRemove": "此操作不可逆,这将删除所有相关数据。",
|
||||||
"orgMessageConfirm": "要确认,请在下面输入组织名称。",
|
"orgMessageConfirm": "要确认,请在下面输入组织名称。",
|
||||||
"orgQuestionRemove": "你确定要删除 “{selectedOrg}” 组织吗?",
|
"orgQuestionRemove": "你确定要删除 \"{selectedOrg}\" 组织吗?",
|
||||||
"orgUpdated": "组织已更新",
|
"orgUpdated": "组织已更新",
|
||||||
"orgUpdatedDescription": "组织已更新。",
|
"orgUpdatedDescription": "组织已更新。",
|
||||||
"orgErrorUpdate": "更新组织失败",
|
"orgErrorUpdate": "更新组织失败",
|
||||||
|
@ -279,7 +279,7 @@
|
||||||
"apiKeysAdd": "生成 API 密钥",
|
"apiKeysAdd": "生成 API 密钥",
|
||||||
"apiKeysErrorDelete": "删除 API 密钥出错",
|
"apiKeysErrorDelete": "删除 API 密钥出错",
|
||||||
"apiKeysErrorDeleteMessage": "删除 API 密钥出错",
|
"apiKeysErrorDeleteMessage": "删除 API 密钥出错",
|
||||||
"apiKeysQuestionRemove": "您确定要从组织中删除 “{selectedApiKey}” API密钥吗?",
|
"apiKeysQuestionRemove": "您确定要从组织中删除 \"{selectedApiKey}\" API密钥吗?",
|
||||||
"apiKeysMessageRemove": "一旦删除,此API密钥将无法被使用。",
|
"apiKeysMessageRemove": "一旦删除,此API密钥将无法被使用。",
|
||||||
"apiKeysMessageConfirm": "要确认,请在下方输入API密钥名称。",
|
"apiKeysMessageConfirm": "要确认,请在下方输入API密钥名称。",
|
||||||
"apiKeysDeleteConfirm": "确认删除 API 密钥",
|
"apiKeysDeleteConfirm": "确认删除 API 密钥",
|
||||||
|
@ -715,7 +715,7 @@
|
||||||
"idpManageDescription": "查看和管理系统中的身份提供商",
|
"idpManageDescription": "查看和管理系统中的身份提供商",
|
||||||
"idpDeletedDescription": "身份提供商删除成功",
|
"idpDeletedDescription": "身份提供商删除成功",
|
||||||
"idpOidc": "OAuth2/OIDC",
|
"idpOidc": "OAuth2/OIDC",
|
||||||
"idpQuestionRemove": "你确定要永久删除 “{name}” 这个身份提供商吗?",
|
"idpQuestionRemove": "你确定要永久删除 \"{name}\" 这个身份提供商吗?",
|
||||||
"idpMessageRemove": "这将删除身份提供者和所有相关的配置。通过此提供者进行身份验证的用户将无法登录。",
|
"idpMessageRemove": "这将删除身份提供者和所有相关的配置。通过此提供者进行身份验证的用户将无法登录。",
|
||||||
"idpMessageConfirm": "要确认,请在下面输入身份提供者的名称。",
|
"idpMessageConfirm": "要确认,请在下面输入身份提供者的名称。",
|
||||||
"idpConfirmDelete": "确认删除身份提供商",
|
"idpConfirmDelete": "确认删除身份提供商",
|
||||||
|
@ -1132,5 +1132,23 @@
|
||||||
"initialSetupTitle": "初始服务器设置",
|
"initialSetupTitle": "初始服务器设置",
|
||||||
"initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。",
|
"initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。",
|
||||||
"createAdminAccount": "创建管理员帐户",
|
"createAdminAccount": "创建管理员帐户",
|
||||||
"setupErrorCreateAdmin": "创建服务器管理员帐户时出错。"
|
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
|
||||||
|
"securityKeyManage": "管理安全密钥",
|
||||||
|
"securityKeyDescription": "添加或删除用于无密码认证的安全密钥",
|
||||||
|
"securityKeyRegister": "注册新的安全密钥",
|
||||||
|
"securityKeyList": "您的安全密钥",
|
||||||
|
"securityKeyNone": "尚未注册安全密钥",
|
||||||
|
"securityKeyNameRequired": "名称为必填项",
|
||||||
|
"securityKeyRemove": "删除",
|
||||||
|
"securityKeyLastUsed": "上次使用:{date}",
|
||||||
|
"securityKeyNameLabel": "名称",
|
||||||
|
"securityKeyNamePlaceholder": "为此安全密钥输入名称",
|
||||||
|
"securityKeyRegisterSuccess": "安全密钥注册成功",
|
||||||
|
"securityKeyRegisterError": "注册安全密钥失败",
|
||||||
|
"securityKeyRemoveSuccess": "安全密钥删除成功",
|
||||||
|
"securityKeyRemoveError": "删除安全密钥失败",
|
||||||
|
"securityKeyLoadError": "加载安全密钥失败",
|
||||||
|
"securityKeyLogin": "使用安全密钥登录",
|
||||||
|
"securityKeyAuthError": "使用安全密钥认证失败",
|
||||||
|
"securityKeyRecommendation": "考虑在其他设备上注册另一个安全密钥,以确保不会被锁定在您的账户之外。"
|
||||||
}
|
}
|
||||||
|
|
4421
package-lock.json
generated
4421
package-lock.json
generated
File diff suppressed because it is too large
Load diff
76
package.json
76
package.json
|
@ -27,8 +27,7 @@
|
||||||
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs"
|
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "^7.3.2",
|
"@asteasolutions/zod-to-openapi": "^7.3.4",
|
||||||
"@aws-sdk/client-s3": "3.837.0",
|
|
||||||
"@hookform/resolvers": "3.9.1",
|
"@hookform/resolvers": "3.9.1",
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
|
@ -51,16 +50,18 @@
|
||||||
"@radix-ui/react-tabs": "1.1.12",
|
"@radix-ui/react-tabs": "1.1.12",
|
||||||
"@radix-ui/react-toast": "1.2.14",
|
"@radix-ui/react-toast": "1.2.14",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@react-email/components": "0.0.41",
|
"@react-email/components": "0.3.1",
|
||||||
"@react-email/render": "^1.1.2",
|
"@react-email/render": "^1.1.2",
|
||||||
"@react-email/tailwind": "1.0.5",
|
"@simplewebauthn/browser": "^13.1.0",
|
||||||
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
|
"@react-email/tailwind": "1.2.1",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"arctic": "^3.7.0",
|
"arctic": "^3.7.0",
|
||||||
"axios": "1.9.0",
|
"axios": "1.10.0",
|
||||||
"better-sqlite3": "11.7.0",
|
"better-sqlite3": "11.7.0",
|
||||||
"canvas-confetti": "1.9.3",
|
"canvas-confetti": "1.9.3",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
|
@ -68,12 +69,12 @@
|
||||||
"cookies": "^0.9.1",
|
"cookies": "^0.9.1",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"drizzle-orm": "0.38.3",
|
"drizzle-orm": "0.44.2",
|
||||||
"eslint": "9.28.0",
|
"eslint": "9.31.0",
|
||||||
"eslint-config-next": "15.3.3",
|
"eslint-config-next": "15.3.5",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
"express-rate-limit": "7.5.0",
|
"express-rate-limit": "7.5.1",
|
||||||
"glob": "11.0.2",
|
"glob": "11.0.3",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "2.0.0",
|
||||||
"i": "^0.3.7",
|
"i": "^0.3.7",
|
||||||
|
@ -82,70 +83,69 @@
|
||||||
"jmespath": "^0.16.0",
|
"jmespath": "^0.16.0",
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "0.511.0",
|
"lucide-react": "0.525.0",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.3.3",
|
"next": "15.3.5",
|
||||||
"next-intl": "^4.1.0",
|
"next-intl": "^4.3.4",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"nodemailer": "6.9.16",
|
"nodemailer": "7.0.5",
|
||||||
"npm": "^11.4.1",
|
"npm": "^11.4.2",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "^8.16.0",
|
"pg": "^8.16.2",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"rate-limit-redis": "^4.2.1",
|
"rate-limit-redis": "^4.2.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-easy-sort": "^1.6.0",
|
"react-easy-sort": "^1.6.0",
|
||||||
"react-hook-form": "7.56.4",
|
"react-hook-form": "7.60.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"rebuild": "0.1.2",
|
"rebuild": "0.1.2",
|
||||||
"semver": "7.7.2",
|
"semver": "^7.7.2",
|
||||||
"stripe": "18.2.1",
|
"tailwind-merge": "3.3.1",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"tw-animate-css": "^1.3.5",
|
||||||
"tailwind-merge": "2.6.0",
|
|
||||||
"tw-animate-css": "^1.3.3",
|
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"winston": "3.17.0",
|
"winston": "3.17.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
"ws": "8.18.2",
|
"ws": "8.18.3",
|
||||||
"yargs": "18.0.0",
|
"zod": "3.25.76",
|
||||||
"zod": "3.25.56",
|
"zod-validation-error": "3.5.2",
|
||||||
"zod-validation-error": "3.4.1"
|
"yargs": "18.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.44.1",
|
"@dotenvx/dotenvx": "1.47.3",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@tailwindcss/postcss": "^4.1.8",
|
"@tailwindcss/postcss": "^4.1.10",
|
||||||
"@types/better-sqlite3": "7.6.12",
|
"@types/better-sqlite3": "7.6.12",
|
||||||
"@types/cookie-parser": "1.4.9",
|
"@types/cookie-parser": "1.4.9",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/express": "5.0.0",
|
"@types/express": "5.0.0",
|
||||||
|
"@types/express-session": "^1.18.2",
|
||||||
"@types/jmespath": "^0.15.2",
|
"@types/jmespath": "^0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "^9.0.9",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^22",
|
"@types/node": "^24",
|
||||||
"@types/nodemailer": "6.4.17",
|
"@types/nodemailer": "6.4.17",
|
||||||
"@types/pg": "8.15.4",
|
"@types/pg": "8.15.4",
|
||||||
"@types/react": "19.1.7",
|
"@types/react": "19.1.8",
|
||||||
"@types/react-dom": "19.1.6",
|
"@types/react-dom": "19.1.6",
|
||||||
"@types/semver": "7.7.0",
|
"@types/semver": "^7.7.0",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.33",
|
||||||
"drizzle-kit": "0.31.1",
|
"drizzle-kit": "0.31.4",
|
||||||
"esbuild": "0.25.5",
|
"esbuild": "0.25.6",
|
||||||
"esbuild-node-externals": "1.18.0",
|
"esbuild-node-externals": "1.18.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"react-email": "4.0.16",
|
"react-email": "4.1.0",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.16",
|
||||||
"tsx": "4.19.4",
|
"tsx": "4.20.3",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"typescript-eslint": "^8.34.0"
|
"typescript-eslint": "^8.36.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"emblor": {
|
"emblor": {
|
||||||
|
|
|
@ -56,6 +56,8 @@ export enum ActionsEnum {
|
||||||
// removeUserAction = "removeUserAction",
|
// removeUserAction = "removeUserAction",
|
||||||
// removeUserSite = "removeUserSite",
|
// removeUserSite = "removeUserSite",
|
||||||
getOrgUser = "getOrgUser",
|
getOrgUser = "getOrgUser",
|
||||||
|
updateUser = "updateUser",
|
||||||
|
getUser = "getUser",
|
||||||
setResourcePassword = "setResourcePassword",
|
setResourcePassword = "setResourcePassword",
|
||||||
setResourcePincode = "setResourcePincode",
|
setResourcePincode = "setResourcePincode",
|
||||||
setResourceWhitelist = "setResourceWhitelist",
|
setResourceWhitelist = "setResourceWhitelist",
|
||||||
|
|
|
@ -132,6 +132,7 @@ export const users = pgTable("user", {
|
||||||
}),
|
}),
|
||||||
passwordHash: varchar("passwordHash"),
|
passwordHash: varchar("passwordHash"),
|
||||||
twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false),
|
twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false),
|
||||||
|
twoFactorSetupRequested: boolean("twoFactorSetupRequested").default(false),
|
||||||
twoFactorSecret: varchar("twoFactorSecret"),
|
twoFactorSecret: varchar("twoFactorSecret"),
|
||||||
emailVerified: boolean("emailVerified").notNull().default(false),
|
emailVerified: boolean("emailVerified").notNull().default(false),
|
||||||
dateCreated: varchar("dateCreated").notNull(),
|
dateCreated: varchar("dateCreated").notNull(),
|
||||||
|
@ -560,6 +561,30 @@ export const roleClients = pgTable("roleClients", {
|
||||||
.references(() => clients.clientId, { onDelete: "cascade" })
|
.references(() => clients.clientId, { onDelete: "cascade" })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const securityKeys = pgTable("webauthnCredentials", {
|
||||||
|
credentialId: varchar("credentialId").primaryKey(),
|
||||||
|
userId: varchar("userId").notNull().references(() => users.userId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
publicKey: varchar("publicKey").notNull(),
|
||||||
|
signCount: integer("signCount").notNull(),
|
||||||
|
transports: varchar("transports"),
|
||||||
|
name: varchar("name"),
|
||||||
|
lastUsed: varchar("lastUsed").notNull(),
|
||||||
|
dateCreated: varchar("dateCreated").notNull(),
|
||||||
|
securityKeyName: varchar("securityKeyName")
|
||||||
|
});
|
||||||
|
|
||||||
|
export const webauthnChallenge = pgTable("webauthnChallenge", {
|
||||||
|
sessionId: varchar("sessionId").primaryKey(),
|
||||||
|
challenge: varchar("challenge").notNull(),
|
||||||
|
securityKeyName: varchar("securityKeyName"),
|
||||||
|
userId: varchar("userId").references(() => users.userId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp
|
||||||
|
});
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
|
|
|
@ -147,6 +147,9 @@ export const users = sqliteTable("user", {
|
||||||
twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
|
twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
twoFactorSetupRequested: integer("twoFactorSetupRequested", {
|
||||||
|
mode: "boolean"
|
||||||
|
}).default(false),
|
||||||
twoFactorSecret: text("twoFactorSecret"),
|
twoFactorSecret: text("twoFactorSecret"),
|
||||||
emailVerified: integer("emailVerified", { mode: "boolean" })
|
emailVerified: integer("emailVerified", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
|
@ -157,6 +160,29 @@ export const users = sqliteTable("user", {
|
||||||
.default(false)
|
.default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const securityKeys = sqliteTable("webauthnCredentials", {
|
||||||
|
credentialId: text("credentialId").primaryKey(),
|
||||||
|
userId: text("userId").notNull().references(() => users.userId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
publicKey: text("publicKey").notNull(),
|
||||||
|
signCount: integer("signCount").notNull(),
|
||||||
|
transports: text("transports"),
|
||||||
|
name: text("name"),
|
||||||
|
lastUsed: text("lastUsed").notNull(),
|
||||||
|
dateCreated: text("dateCreated").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const webauthnChallenge = sqliteTable("webauthnChallenge", {
|
||||||
|
sessionId: text("sessionId").primaryKey(),
|
||||||
|
challenge: text("challenge").notNull(),
|
||||||
|
securityKeyName: text("securityKeyName"),
|
||||||
|
userId: text("userId").references(() => users.userId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
expiresAt: integer("expiresAt").notNull() // Unix timestamp
|
||||||
|
});
|
||||||
|
|
||||||
export const newts = sqliteTable("newt", {
|
export const newts = sqliteTable("newt", {
|
||||||
newtId: text("id").primaryKey(),
|
newtId: text("id").primaryKey(),
|
||||||
secretHash: text("secretHash").notNull(),
|
secretHash: text("secretHash").notNull(),
|
||||||
|
|
|
@ -36,7 +36,7 @@ declare global {
|
||||||
interface Request {
|
interface Request {
|
||||||
apiKey?: ApiKey;
|
apiKey?: ApiKey;
|
||||||
user?: User;
|
user?: User;
|
||||||
session?: Session;
|
session: Session;
|
||||||
userOrg?: UserOrg;
|
userOrg?: UserOrg;
|
||||||
apiKeyOrg?: ApiKeyOrg;
|
apiKeyOrg?: ApiKeyOrg;
|
||||||
userOrgRoleId?: number;
|
userOrgRoleId?: number;
|
||||||
|
|
10
server/lib/totp.ts
Normal file
10
server/lib/totp.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { alphabet, generateRandomString } from "oslo/crypto";
|
||||||
|
|
||||||
|
export async function generateBackupCodes(): Promise<string[]> {
|
||||||
|
const codes = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const code = generateRandomString(6, alphabet("0-9", "A-Z", "a-z"));
|
||||||
|
codes.push(code);
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}
|
|
@ -6,9 +6,10 @@ export * from "./requestTotpSecret";
|
||||||
export * from "./disable2fa";
|
export * from "./disable2fa";
|
||||||
export * from "./verifyEmail";
|
export * from "./verifyEmail";
|
||||||
export * from "./requestEmailVerificationCode";
|
export * from "./requestEmailVerificationCode";
|
||||||
export * from "./changePassword";
|
|
||||||
export * from "./requestPasswordReset";
|
|
||||||
export * from "./resetPassword";
|
export * from "./resetPassword";
|
||||||
export * from "./checkResourceSession";
|
export * from "./requestPasswordReset";
|
||||||
export * from "./setServerAdmin";
|
export * from "./setServerAdmin";
|
||||||
export * from "./initialSetupComplete";
|
export * from "./initialSetupComplete";
|
||||||
|
export * from "./changePassword";
|
||||||
|
export * from "./checkResourceSession";
|
||||||
|
export * from "./securityKey";
|
||||||
|
|
|
@ -4,7 +4,7 @@ import {
|
||||||
serializeSessionCookie
|
serializeSessionCookie
|
||||||
} from "@server/auth/sessions/app";
|
} from "@server/auth/sessions/app";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { users } from "@server/db";
|
import { users, securityKeys } from "@server/db";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
@ -21,10 +21,7 @@ import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
export const loginBodySchema = z
|
export const loginBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z
|
email: z.string().toLowerCase().email(),
|
||||||
.string()
|
|
||||||
.toLowerCase()
|
|
||||||
.email(),
|
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
code: z.string().optional()
|
code: z.string().optional()
|
||||||
})
|
})
|
||||||
|
@ -35,10 +32,10 @@ export type LoginBody = z.infer<typeof loginBodySchema>;
|
||||||
export type LoginResponse = {
|
export type LoginResponse = {
|
||||||
codeRequested?: boolean;
|
codeRequested?: boolean;
|
||||||
emailVerificationRequired?: boolean;
|
emailVerificationRequired?: boolean;
|
||||||
|
useSecurityKey?: boolean;
|
||||||
|
twoFactorSetupRequired?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function login(
|
export async function login(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
@ -109,6 +106,35 @@ export async function login(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user has security keys registered
|
||||||
|
const userSecurityKeys = await db
|
||||||
|
.select()
|
||||||
|
.from(securityKeys)
|
||||||
|
.where(eq(securityKeys.userId, existingUser.userId));
|
||||||
|
|
||||||
|
if (userSecurityKeys.length > 0) {
|
||||||
|
return response<LoginResponse>(res, {
|
||||||
|
data: { useSecurityKey: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Security key authentication required",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingUser.twoFactorSetupRequested &&
|
||||||
|
!existingUser.twoFactorEnabled
|
||||||
|
) {
|
||||||
|
return response<LoginResponse>(res, {
|
||||||
|
data: { twoFactorSetupRequired: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Two-factor authentication setup required",
|
||||||
|
status: HttpCode.ACCEPTED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (existingUser.twoFactorEnabled) {
|
if (existingUser.twoFactorEnabled) {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return response<{ codeRequested: boolean }>(res, {
|
return response<{ codeRequested: boolean }>(res, {
|
||||||
|
|
|
@ -7,17 +7,19 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import { response } from "@server/lib";
|
import { response } from "@server/lib";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { User, users } from "@server/db";
|
import { User, users } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { createTOTPKeyURI } from "oslo/otp";
|
import { createTOTPKeyURI } from "oslo/otp";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
import config from "@server/lib/config";
|
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const requestTotpSecretBody = z
|
export const requestTotpSecretBody = z
|
||||||
.object({
|
.object({
|
||||||
password: z.string()
|
password: z.string(),
|
||||||
|
email: z.string().email().optional()
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
@ -44,9 +46,42 @@ export async function requestTotpSecret(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { password } = parsedBody.data;
|
const { password, email } = parsedBody.data;
|
||||||
|
|
||||||
const user = req.user as User;
|
const { user: sessionUser, session: existingSession } = await verifySession(req);
|
||||||
|
|
||||||
|
let user: User | null = sessionUser;
|
||||||
|
if (!existingSession) {
|
||||||
|
if (!email) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Email is required for two-factor authentication setup"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [res] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(
|
||||||
|
and(eq(users.type, UserType.Internal), eq(users.email, email))
|
||||||
|
);
|
||||||
|
user = res;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
"Username or password is incorrect"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (user.type !== UserType.Internal) {
|
if (user.type !== UserType.Internal) {
|
||||||
return next(
|
return next(
|
||||||
|
@ -58,7 +93,10 @@ export async function requestTotpSecret(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validPassword = await verifyPassword(password, user.passwordHash!);
|
const validPassword = await verifyPassword(
|
||||||
|
password,
|
||||||
|
user.passwordHash!
|
||||||
|
);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return next(unauthorized());
|
return next(unauthorized());
|
||||||
}
|
}
|
||||||
|
|
717
server/routers/auth/securityKey.ts
Normal file
717
server/routers/auth/securityKey.ts
Normal file
|
@ -0,0 +1,717 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { User, securityKeys, users, webauthnChallenge } from "@server/db";
|
||||||
|
import { eq, and, lt } from "drizzle-orm";
|
||||||
|
import { response } from "@server/lib";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import {
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
verifyAuthenticationResponse
|
||||||
|
} from "@simplewebauthn/server";
|
||||||
|
import type {
|
||||||
|
GenerateRegistrationOptionsOpts,
|
||||||
|
VerifyRegistrationResponseOpts,
|
||||||
|
GenerateAuthenticationOptionsOpts,
|
||||||
|
VerifyAuthenticationResponseOpts,
|
||||||
|
VerifiedRegistrationResponse,
|
||||||
|
VerifiedAuthenticationResponse
|
||||||
|
} from "@simplewebauthn/server";
|
||||||
|
import type {
|
||||||
|
AuthenticatorTransport,
|
||||||
|
AuthenticatorTransportFuture,
|
||||||
|
PublicKeyCredentialDescriptorJSON,
|
||||||
|
PublicKeyCredentialDescriptorFuture
|
||||||
|
} from "@simplewebauthn/types";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
|
import { verifyTotpCode } from "@server/auth/totp";
|
||||||
|
|
||||||
|
// The RP ID is the domain name of your application
|
||||||
|
const rpID = (() => {
|
||||||
|
const url = new URL(config.getRawConfig().app.dashboard_url);
|
||||||
|
// For localhost, we must use 'localhost' without port
|
||||||
|
if (url.hostname === 'localhost') {
|
||||||
|
return 'localhost';
|
||||||
|
}
|
||||||
|
return url.hostname;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const rpName = "Pangolin";
|
||||||
|
const origin = config.getRawConfig().app.dashboard_url;
|
||||||
|
|
||||||
|
// Database-based challenge storage (replaces in-memory storage)
|
||||||
|
// Challenges are stored in the webauthnChallenge table with automatic expiration
|
||||||
|
// This supports clustered deployments and persists across server restarts
|
||||||
|
|
||||||
|
// Clean up expired challenges every 5 minutes
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
await db
|
||||||
|
.delete(webauthnChallenge)
|
||||||
|
.where(lt(webauthnChallenge.expiresAt, now));
|
||||||
|
logger.debug("Cleaned up expired security key challenges");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to clean up expired security key challenges", error);
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
// Helper functions for challenge management
|
||||||
|
async function storeChallenge(sessionId: string, challenge: string, securityKeyName?: string, userId?: string) {
|
||||||
|
const expiresAt = Date.now() + (5 * 60 * 1000); // 5 minutes
|
||||||
|
|
||||||
|
// Delete any existing challenge for this session
|
||||||
|
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
||||||
|
|
||||||
|
// Insert new challenge
|
||||||
|
await db.insert(webauthnChallenge).values({
|
||||||
|
sessionId,
|
||||||
|
challenge,
|
||||||
|
securityKeyName,
|
||||||
|
userId,
|
||||||
|
expiresAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChallenge(sessionId: string) {
|
||||||
|
const [challengeData] = await db
|
||||||
|
.select()
|
||||||
|
.from(webauthnChallenge)
|
||||||
|
.where(eq(webauthnChallenge.sessionId, sessionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!challengeData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (challengeData.expiresAt < Date.now()) {
|
||||||
|
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return challengeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearChallenge(sessionId: string) {
|
||||||
|
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const registerSecurityKeyBody = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
code: z.string().optional()
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const verifyRegistrationBody = z.object({
|
||||||
|
credential: z.any()
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const startAuthenticationBody = z.object({
|
||||||
|
email: z.string().email().optional()
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const verifyAuthenticationBody = z.object({
|
||||||
|
credential: z.any()
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const deleteSecurityKeyBody = z.object({
|
||||||
|
password: z.string().min(1),
|
||||||
|
code: z.string().optional()
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export async function startRegistration(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
const parsedBody = registerSecurityKeyBody.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, password, code } = parsedBody.data;
|
||||||
|
const user = req.user as User;
|
||||||
|
|
||||||
|
// Only allow internal users to use security keys
|
||||||
|
if (user.type !== UserType.Internal) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Security keys are only available for internal users"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify password
|
||||||
|
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||||
|
if (!validPassword) {
|
||||||
|
return next(unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has 2FA enabled, require and verify the code
|
||||||
|
if (user.twoFactorEnabled) {
|
||||||
|
if (!code) {
|
||||||
|
return response<{ codeRequested: boolean }>(res, {
|
||||||
|
data: { codeRequested: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Two-factor authentication required",
|
||||||
|
status: HttpCode.ACCEPTED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validOTP = await verifyTotpCode(
|
||||||
|
code,
|
||||||
|
user.twoFactorSecret!,
|
||||||
|
user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validOTP) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Two-factor code incorrect. Email: ${user.email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
"The two-factor code you entered is incorrect"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing security keys for user
|
||||||
|
const existingSecurityKeys = await db
|
||||||
|
.select()
|
||||||
|
.from(securityKeys)
|
||||||
|
.where(eq(securityKeys.userId, user.userId));
|
||||||
|
|
||||||
|
const excludeCredentials = existingSecurityKeys.map(key => ({
|
||||||
|
id: new Uint8Array(Buffer.from(key.credentialId, 'base64')),
|
||||||
|
type: 'public-key' as const,
|
||||||
|
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransportFuture[] : undefined
|
||||||
|
}));
|
||||||
|
|
||||||
|
const options: GenerateRegistrationOptionsOpts = {
|
||||||
|
rpName,
|
||||||
|
rpID,
|
||||||
|
userID: user.userId,
|
||||||
|
userName: user.email || user.username,
|
||||||
|
attestationType: 'none',
|
||||||
|
excludeCredentials,
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: 'preferred',
|
||||||
|
userVerification: 'preferred',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const registrationOptions = await generateRegistrationOptions(options);
|
||||||
|
|
||||||
|
// Store challenge in database
|
||||||
|
await storeChallenge(req.session.sessionId, registrationOptions.challenge, name, user.userId);
|
||||||
|
|
||||||
|
return response<typeof registrationOptions>(res, {
|
||||||
|
data: registrationOptions,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Registration options generated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to start registration"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyRegistration(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
const parsedBody = verifyRegistrationBody.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { credential } = parsedBody.data;
|
||||||
|
const user = req.user as User;
|
||||||
|
|
||||||
|
// Only allow internal users to use security keys
|
||||||
|
if (user.type !== UserType.Internal) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Security keys are only available for internal users"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get challenge from database
|
||||||
|
const challengeData = await getChallenge(req.session.sessionId);
|
||||||
|
|
||||||
|
if (!challengeData) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"No challenge found in session or challenge expired"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = await verifyRegistrationResponse({
|
||||||
|
response: credential,
|
||||||
|
expectedChallenge: challengeData.challenge,
|
||||||
|
expectedOrigin: origin,
|
||||||
|
expectedRPID: rpID,
|
||||||
|
requireUserVerification: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { verified, registrationInfo } = verification;
|
||||||
|
|
||||||
|
if (!verified || !registrationInfo) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Verification failed"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the security key in the database
|
||||||
|
await db.insert(securityKeys).values({
|
||||||
|
credentialId: Buffer.from(registrationInfo.credentialID).toString('base64'),
|
||||||
|
userId: user.userId,
|
||||||
|
publicKey: Buffer.from(registrationInfo.credentialPublicKey).toString('base64'),
|
||||||
|
signCount: registrationInfo.counter || 0,
|
||||||
|
transports: credential.response.transports ? JSON.stringify(credential.response.transports) : null,
|
||||||
|
name: challengeData.securityKeyName,
|
||||||
|
lastUsed: new Date().toISOString(),
|
||||||
|
dateCreated: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear challenge data
|
||||||
|
await clearChallenge(req.session.sessionId);
|
||||||
|
|
||||||
|
return response<null>(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Security key registered successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to verify registration"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSecurityKeys(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
const user = req.user as User;
|
||||||
|
|
||||||
|
// Only allow internal users to use security keys
|
||||||
|
if (user.type !== UserType.Internal) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Security keys are only available for internal users"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userSecurityKeys = await db
|
||||||
|
.select()
|
||||||
|
.from(securityKeys)
|
||||||
|
.where(eq(securityKeys.userId, user.userId));
|
||||||
|
|
||||||
|
return response<typeof userSecurityKeys>(res, {
|
||||||
|
data: userSecurityKeys,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Security keys retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to retrieve security keys"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSecurityKey(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
const { credentialId: encodedCredentialId } = req.params;
|
||||||
|
const credentialId = decodeURIComponent(encodedCredentialId);
|
||||||
|
const user = req.user as User;
|
||||||
|
|
||||||
|
const parsedBody = deleteSecurityKeyBody.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password, code } = parsedBody.data;
|
||||||
|
|
||||||
|
// Only allow internal users to use security keys
|
||||||
|
if (user.type !== UserType.Internal) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Security keys are only available for internal users"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify password
|
||||||
|
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||||
|
if (!validPassword) {
|
||||||
|
return next(unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has 2FA enabled, require and verify the code
|
||||||
|
if (user.twoFactorEnabled) {
|
||||||
|
if (!code) {
|
||||||
|
return response<{ codeRequested: boolean }>(res, {
|
||||||
|
data: { codeRequested: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Two-factor authentication required",
|
||||||
|
status: HttpCode.ACCEPTED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validOTP = await verifyTotpCode(
|
||||||
|
code,
|
||||||
|
user.twoFactorSecret!,
|
||||||
|
user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validOTP) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Two-factor code incorrect. Email: ${user.email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
"The two-factor code you entered is incorrect"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(securityKeys)
|
||||||
|
.where(and(
|
||||||
|
eq(securityKeys.credentialId, credentialId),
|
||||||
|
eq(securityKeys.userId, user.userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
return response<null>(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Security key deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to delete security key"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startAuthentication(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
const parsedBody = startAuthenticationBody.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email } = parsedBody.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let allowCredentials: PublicKeyCredentialDescriptorFuture[] = [];
|
||||||
|
let userId;
|
||||||
|
|
||||||
|
// If email is provided, get security keys for that specific user
|
||||||
|
if (email) {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.email, email))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user || user.type !== UserType.Internal) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid credentials"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
userId = user.userId;
|
||||||
|
|
||||||
|
const userSecurityKeys = await db
|
||||||
|
.select()
|
||||||
|
.from(securityKeys)
|
||||||
|
.where(eq(securityKeys.userId, user.userId));
|
||||||
|
|
||||||
|
if (userSecurityKeys.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"No security keys registered for this user"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
allowCredentials = userSecurityKeys.map(key => ({
|
||||||
|
id: new Uint8Array(Buffer.from(key.credentialId, 'base64')),
|
||||||
|
type: 'public-key' as const,
|
||||||
|
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransportFuture[] : undefined
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// If no email provided, allow any security key (for resident key authentication)
|
||||||
|
allowCredentials = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: GenerateAuthenticationOptionsOpts = {
|
||||||
|
rpID,
|
||||||
|
allowCredentials,
|
||||||
|
userVerification: 'preferred',
|
||||||
|
};
|
||||||
|
|
||||||
|
const authenticationOptions = await generateAuthenticationOptions(options);
|
||||||
|
|
||||||
|
// Generate a temporary session ID for unauthenticated users
|
||||||
|
const tempSessionId = email ? `temp_${email}_${Date.now()}` : `temp_${Date.now()}`;
|
||||||
|
|
||||||
|
// Store challenge in database
|
||||||
|
await storeChallenge(tempSessionId, authenticationOptions.challenge, undefined, userId);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: { ...authenticationOptions, tempSessionId },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Authentication options generated",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to generate authentication options"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAuthentication(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
const parsedBody = verifyAuthenticationBody.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { credential } = parsedBody.data;
|
||||||
|
const tempSessionId = req.headers['x-temp-session-id'] as string;
|
||||||
|
|
||||||
|
if (!tempSessionId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Your session information is missing. This might happen if you've been inactive for too long or if your browser cleared temporary data. Please start the sign-in process again."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get challenge from database
|
||||||
|
const challengeData = await getChallenge(tempSessionId);
|
||||||
|
|
||||||
|
if (!challengeData) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Your sign-in session has expired. For security reasons, you have 5 minutes to complete the authentication process. Please try signing in again."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the security key in database
|
||||||
|
const credentialId = Buffer.from(credential.id, 'base64').toString('base64');
|
||||||
|
const [securityKey] = await db
|
||||||
|
.select()
|
||||||
|
.from(securityKeys)
|
||||||
|
.where(eq(securityKeys.credentialId, credentialId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!securityKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"We couldn't verify your security key. This might happen if your device isn't compatible or if the security key was removed too quickly. Please try again and keep your security key connected until the process completes."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the user
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.userId, securityKey.userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user || user.type !== UserType.Internal) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"User not found or not authorized for security key authentication"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = await verifyAuthenticationResponse({
|
||||||
|
response: credential,
|
||||||
|
expectedChallenge: challengeData.challenge,
|
||||||
|
expectedOrigin: origin,
|
||||||
|
expectedRPID: rpID,
|
||||||
|
authenticator: {
|
||||||
|
credentialID: Buffer.from(securityKey.credentialId, 'base64'),
|
||||||
|
credentialPublicKey: Buffer.from(securityKey.publicKey, 'base64'),
|
||||||
|
counter: securityKey.signCount,
|
||||||
|
transports: securityKey.transports ? JSON.parse(securityKey.transports) as AuthenticatorTransportFuture[] : undefined
|
||||||
|
},
|
||||||
|
requireUserVerification: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { verified, authenticationInfo } = verification;
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Authentication failed. This could happen if your security key wasn't recognized or was removed too early. Please ensure your security key is properly connected and try again."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sign count
|
||||||
|
await db
|
||||||
|
.update(securityKeys)
|
||||||
|
.set({
|
||||||
|
signCount: authenticationInfo.newCounter,
|
||||||
|
lastUsed: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.where(eq(securityKeys.credentialId, credentialId));
|
||||||
|
|
||||||
|
// Create session for the user
|
||||||
|
const { createSession, generateSessionToken, serializeSessionCookie } = await import("@server/auth/sessions/app");
|
||||||
|
const token = generateSessionToken();
|
||||||
|
const session = await createSession(token, user.userId);
|
||||||
|
const isSecure = req.protocol === "https";
|
||||||
|
const cookie = serializeSessionCookie(
|
||||||
|
token,
|
||||||
|
isSecure,
|
||||||
|
new Date(session.expiresAt)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.setHeader("Set-Cookie", cookie);
|
||||||
|
|
||||||
|
// Clear challenge data
|
||||||
|
await clearChallenge(tempSessionId);
|
||||||
|
|
||||||
|
return response<null>(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Authentication successful",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to verify authentication"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,18 +6,22 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import { response } from "@server/lib";
|
import { response } from "@server/lib";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { twoFactorBackupCodes, User, users } from "@server/db";
|
import { twoFactorBackupCodes, User, users } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { alphabet, generateRandomString } from "oslo/crypto";
|
import { hashPassword, verifyPassword } from "@server/auth/password";
|
||||||
import { hashPassword } from "@server/auth/password";
|
|
||||||
import { verifyTotpCode } from "@server/auth/totp";
|
import { verifyTotpCode } from "@server/auth/totp";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { sendEmail } from "@server/emails";
|
import { sendEmail } from "@server/emails";
|
||||||
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
|
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import { generateBackupCodes } from "@server/lib/totp";
|
||||||
|
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||||
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
|
|
||||||
export const verifyTotpBody = z
|
export const verifyTotpBody = z
|
||||||
.object({
|
.object({
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
password: z.string().optional(),
|
||||||
code: z.string()
|
code: z.string()
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
@ -45,38 +49,83 @@ export async function verifyTotp(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { code } = parsedBody.data;
|
const { code, email, password } = parsedBody.data;
|
||||||
|
|
||||||
const user = req.user as User;
|
|
||||||
|
|
||||||
if (user.type !== UserType.Internal) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Two-factor authentication is not supported for external users"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.twoFactorEnabled) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Two-factor authentication is already enabled"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.twoFactorSecret) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"User has not requested two-factor authentication"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const { user: sessionUser, session: existingSession } =
|
||||||
|
await verifySession(req);
|
||||||
|
|
||||||
|
let user: User | null = sessionUser;
|
||||||
|
if (!existingSession) {
|
||||||
|
if (!email || !password) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Email and password are required for two-factor authentication"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [res] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(users.type, UserType.Internal),
|
||||||
|
eq(users.email, email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
user = res;
|
||||||
|
|
||||||
|
const validPassword = await verifyPassword(
|
||||||
|
password,
|
||||||
|
user.passwordHash!
|
||||||
|
);
|
||||||
|
if (!validPassword) {
|
||||||
|
return next(unauthorized());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
"Username or password is incorrect"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.type !== UserType.Internal) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Two-factor authentication is not supported for external users"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.twoFactorEnabled) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Two-factor authentication is already enabled"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.twoFactorSecret) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"User has not requested two-factor authentication"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const valid = await verifyTotpCode(
|
const valid = await verifyTotpCode(
|
||||||
code,
|
code,
|
||||||
user.twoFactorSecret,
|
user.twoFactorSecret,
|
||||||
|
@ -89,7 +138,9 @@ export async function verifyTotp(
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ twoFactorEnabled: true })
|
.set({
|
||||||
|
twoFactorEnabled: true
|
||||||
|
})
|
||||||
.where(eq(users.userId, user.userId));
|
.where(eq(users.userId, user.userId));
|
||||||
|
|
||||||
const backupCodes = await generateBackupCodes();
|
const backupCodes = await generateBackupCodes();
|
||||||
|
@ -153,12 +204,3 @@ export async function verifyTotp(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateBackupCodes(): Promise<string[]> {
|
|
||||||
const codes = [];
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const code = generateRandomString(6, alphabet("0-9", "A-Z", "a-z"));
|
|
||||||
codes.push(code);
|
|
||||||
}
|
|
||||||
return codes;
|
|
||||||
}
|
|
||||||
|
|
|
@ -535,6 +535,7 @@ unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
|
||||||
unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
|
unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
|
||||||
|
|
||||||
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
|
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
|
||||||
|
authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser);
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/user/:userId",
|
"/user/:userId",
|
||||||
verifyUserIsServerAdmin,
|
verifyUserIsServerAdmin,
|
||||||
|
@ -550,6 +551,12 @@ authenticated.put(
|
||||||
|
|
||||||
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/user/:userId/2fa",
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
user.updateUser2FA
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/users",
|
"/org/:orgId/users",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
@ -795,12 +802,8 @@ authRouter.post("/logout", auth.logout);
|
||||||
authRouter.post("/newt/get-token", getNewtToken);
|
authRouter.post("/newt/get-token", getNewtToken);
|
||||||
authRouter.post("/olm/get-token", getOlmToken);
|
authRouter.post("/olm/get-token", getOlmToken);
|
||||||
|
|
||||||
authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp);
|
authRouter.post("/2fa/enable", auth.verifyTotp);
|
||||||
authRouter.post(
|
authRouter.post("/2fa/request", auth.requestTotpSecret);
|
||||||
"/2fa/request",
|
|
||||||
verifySessionUserMiddleware,
|
|
||||||
auth.requestTotpSecret
|
|
||||||
);
|
|
||||||
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
|
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
|
||||||
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
|
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
|
||||||
|
|
||||||
|
@ -874,3 +877,36 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
|
||||||
|
|
||||||
authRouter.put("/set-server-admin", auth.setServerAdmin);
|
authRouter.put("/set-server-admin", auth.setServerAdmin);
|
||||||
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
|
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
|
||||||
|
|
||||||
|
// Security Key routes
|
||||||
|
authRouter.post(
|
||||||
|
"/security-key/register/start",
|
||||||
|
verifySessionUserMiddleware,
|
||||||
|
rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 5, // Allow 5 security key registrations per 15 minutes
|
||||||
|
keyGenerator: (req) => `securityKeyRegister:${req.user?.userId}`,
|
||||||
|
handler: (req, res, next) => {
|
||||||
|
const message = `You can only register ${5} security keys every ${15} minutes. Please try again later.`;
|
||||||
|
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
auth.startRegistration
|
||||||
|
);
|
||||||
|
authRouter.post("/security-key/register/verify", verifySessionUserMiddleware, auth.verifyRegistration);
|
||||||
|
authRouter.post(
|
||||||
|
"/security-key/authenticate/start",
|
||||||
|
rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 10, // Allow 10 authentication attempts per 15 minutes per IP
|
||||||
|
keyGenerator: (req) => `securityKeyAuth:${req.ip}`,
|
||||||
|
handler: (req, res, next) => {
|
||||||
|
const message = `You can only attempt security key authentication ${10} times every ${15} minutes. Please try again later.`;
|
||||||
|
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
auth.startAuthentication
|
||||||
|
);
|
||||||
|
authRouter.post("/security-key/authenticate/verify", auth.verifyAuthentication);
|
||||||
|
authRouter.get("/security-key/list", verifySessionUserMiddleware, auth.listSecurityKeys);
|
||||||
|
authRouter.delete("/security-key/:credentialId", verifySessionUserMiddleware, auth.deleteSecurityKey);
|
||||||
|
|
|
@ -381,6 +381,20 @@ authenticated.get(
|
||||||
user.getOrgUser
|
user.getOrgUser
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/user/:userId/2fa",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateUser),
|
||||||
|
user.updateUser2FA
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/user/:userId",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getUser),
|
||||||
|
user.adminGetUser
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/users",
|
"/org/:orgId/users",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
|
94
server/routers/user/adminGetUser.ts
Normal file
94
server/routers/user/adminGetUser.ts
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { idp, users } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const adminGetUserSchema = z
|
||||||
|
.object({
|
||||||
|
userId: z.string().min(1)
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/user/{userId}",
|
||||||
|
description: "Get a user by ID.",
|
||||||
|
tags: [OpenAPITags.User],
|
||||||
|
request: {
|
||||||
|
params: adminGetUserSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function queryUser(userId: string) {
|
||||||
|
const [user] = await db
|
||||||
|
.select({
|
||||||
|
userId: users.userId,
|
||||||
|
email: users.email,
|
||||||
|
username: users.username,
|
||||||
|
name: users.name,
|
||||||
|
type: users.type,
|
||||||
|
twoFactorEnabled: users.twoFactorEnabled,
|
||||||
|
twoFactorSetupRequested: users.twoFactorSetupRequested,
|
||||||
|
emailVerified: users.emailVerified,
|
||||||
|
serverAdmin: users.serverAdmin,
|
||||||
|
idpName: idp.name,
|
||||||
|
idpId: users.idpId,
|
||||||
|
dateCreated: users.dateCreated
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||||
|
.where(eq(users.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminGetUserResponse = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof queryUser>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export async function adminGetUser(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = adminGetUserSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { userId } = parsedParams.data;
|
||||||
|
|
||||||
|
const user = await queryUser(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`User with ID ${userId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<AdminGetUserResponse>(res, {
|
||||||
|
data: user,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "User retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,7 +37,9 @@ async function queryUsers(limit: number, offset: number) {
|
||||||
serverAdmin: users.serverAdmin,
|
serverAdmin: users.serverAdmin,
|
||||||
type: users.type,
|
type: users.type,
|
||||||
idpName: idp.name,
|
idpName: idp.name,
|
||||||
idpId: users.idpId
|
idpId: users.idpId,
|
||||||
|
twoFactorEnabled: users.twoFactorEnabled,
|
||||||
|
twoFactorSetupRequested: users.twoFactorSetupRequested
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||||
|
|
133
server/routers/user/adminUpdateUser2FA.ts
Normal file
133
server/routers/user/adminUpdateUser2FA.ts
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { users, userOrgs } from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const updateUser2FAParamsSchema = z
|
||||||
|
.object({
|
||||||
|
userId: z.string()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const updateUser2FABodySchema = z
|
||||||
|
.object({
|
||||||
|
twoFactorSetupRequested: z.boolean()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type UpdateUser2FAResponse = {
|
||||||
|
userId: string;
|
||||||
|
twoFactorRequested: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/user/{userId}/2fa",
|
||||||
|
description: "Update a user's 2FA status.",
|
||||||
|
tags: [OpenAPITags.User],
|
||||||
|
request: {
|
||||||
|
params: updateUser2FAParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: updateUser2FABodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateUser2FA(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = updateUser2FAParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = updateUser2FABodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = parsedParams.data;
|
||||||
|
const { twoFactorSetupRequested } = parsedBody.data;
|
||||||
|
|
||||||
|
// Verify the user exists in the organization
|
||||||
|
const existingUser = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUser.length === 0) {
|
||||||
|
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingUser[0].type !== "internal") {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Two-factor authentication is not supported for external users"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Updating 2FA for user ${userId} to ${twoFactorSetupRequested}`);
|
||||||
|
|
||||||
|
if (twoFactorSetupRequested) {
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
twoFactorSetupRequested: true,
|
||||||
|
})
|
||||||
|
.where(eq(users.userId, userId));
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
twoFactorSetupRequested: false,
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
twoFactorSecret: null
|
||||||
|
})
|
||||||
|
.where(eq(users.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<UpdateUser2FAResponse>(res, {
|
||||||
|
data: {
|
||||||
|
userId: existingUser[0].userId,
|
||||||
|
twoFactorRequested: twoFactorSetupRequested
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: `2FA ${twoFactorSetupRequested ? "enabled" : "disabled"} for user successfully`,
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -23,7 +23,8 @@ async function queryUser(orgId: string, userId: string) {
|
||||||
roleId: userOrgs.roleId,
|
roleId: userOrgs.roleId,
|
||||||
roleName: roles.name,
|
roleName: roles.name,
|
||||||
isOwner: userOrgs.isOwner,
|
isOwner: userOrgs.isOwner,
|
||||||
isAdmin: roles.isAdmin
|
isAdmin: roles.isAdmin,
|
||||||
|
twoFactorEnabled: users.twoFactorEnabled,
|
||||||
})
|
})
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||||
|
|
|
@ -7,6 +7,9 @@ export * from "./acceptInvite";
|
||||||
export * from "./getOrgUser";
|
export * from "./getOrgUser";
|
||||||
export * from "./adminListUsers";
|
export * from "./adminListUsers";
|
||||||
export * from "./adminRemoveUser";
|
export * from "./adminRemoveUser";
|
||||||
|
export * from "./adminGetUser";
|
||||||
export * from "./listInvitations";
|
export * from "./listInvitations";
|
||||||
export * from "./removeInvitation";
|
export * from "./removeInvitation";
|
||||||
export * from "./createOrgUser";
|
export * from "./createOrgUser";
|
||||||
|
export * from "./adminUpdateUser2FA";
|
||||||
|
export * from "./adminGetUser";
|
||||||
|
|
|
@ -49,7 +49,8 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
|
||||||
roleName: roles.name,
|
roleName: roles.name,
|
||||||
isOwner: userOrgs.isOwner,
|
isOwner: userOrgs.isOwner,
|
||||||
idpName: idp.name,
|
idpName: idp.name,
|
||||||
idpId: users.idpId
|
idpId: users.idpId,
|
||||||
|
twoFactorEnabled: users.twoFactorEnabled,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
|
|
@ -4,7 +4,7 @@ import semver from "semver";
|
||||||
import { versionMigrations } from "../db/pg";
|
import { versionMigrations } from "../db/pg";
|
||||||
import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
|
import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import m1 from "./scriptsSqlite/1.6.0";
|
import m1 from "./scriptsPg/1.6.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
|
|
|
@ -262,7 +262,19 @@ export default function ReverseProxyTargets(props: {
|
||||||
// make sure that the target IP is within the site subnet
|
// make sure that the target IP is within the site subnet
|
||||||
const targetIp = data.ip;
|
const targetIp = data.ip;
|
||||||
const subnet = site.subnet;
|
const subnet = site.subnet;
|
||||||
if (!isIPInSubnet(targetIp, subnet)) {
|
try {
|
||||||
|
if (!isIPInSubnet(targetIp, subnet)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("targetWireGuardErrorInvalidIp"),
|
||||||
|
description: t(
|
||||||
|
"targetWireGuardErrorInvalidIpDescription"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("targetWireGuardErrorInvalidIp"),
|
title: t("targetWireGuardErrorInvalidIp"),
|
||||||
|
@ -885,10 +897,8 @@ function isIPInSubnet(subnet: string, ip: string): boolean {
|
||||||
const [subnetIP, maskBits] = subnet.split("/");
|
const [subnetIP, maskBits] = subnet.split("/");
|
||||||
const mask = parseInt(maskBits);
|
const mask = parseInt(maskBits);
|
||||||
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
if (mask < 0 || mask > 32) {
|
if (mask < 0 || mask > 32) {
|
||||||
throw new Error(t("subnetMaskErrorInvalid"));
|
throw new Error("subnetMaskErrorInvalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert IP addresses to binary numbers
|
// Convert IP addresses to binary numbers
|
||||||
|
@ -905,17 +915,16 @@ function isIPInSubnet(subnet: string, ip: string): boolean {
|
||||||
function ipToNumber(ip: string): number {
|
function ipToNumber(ip: string): number {
|
||||||
// Validate IP address format
|
// Validate IP address format
|
||||||
const parts = ip.split(".");
|
const parts = ip.split(".");
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
if (parts.length !== 4) {
|
if (parts.length !== 4) {
|
||||||
throw new Error(t("ipAddressErrorInvalidFormat"));
|
throw new Error("ipAddressErrorInvalidFormat");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert IP octets to 32-bit number
|
// Convert IP octets to 32-bit number
|
||||||
return parts.reduce((num, octet) => {
|
return parts.reduce((num, octet) => {
|
||||||
const oct = parseInt(octet);
|
const oct = parseInt(octet);
|
||||||
if (isNaN(oct) || oct < 0 || oct > 255) {
|
if (isNaN(oct) || oct < 0 || oct > 255) {
|
||||||
throw new Error(t("ipAddressErrorInvalidOctet"));
|
throw new Error("ipAddressErrorInvalidOctet");
|
||||||
}
|
}
|
||||||
return (num << 8) + oct;
|
return (num << 8) + oct;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { UsersDataTable } from "./AdminUsersDataTable";
|
import { UsersDataTable } from "./AdminUsersDataTable";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { ArrowRight, ArrowUpDown } from "lucide-react";
|
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
|
@ -12,6 +12,12 @@ import { formatAxiosError } from "@app/lib/api";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
|
||||||
export type GlobalUserRow = {
|
export type GlobalUserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -22,6 +28,8 @@ export type GlobalUserRow = {
|
||||||
idpId: number | null;
|
idpId: number | null;
|
||||||
idpName: string;
|
idpName: string;
|
||||||
dateCreated: string;
|
dateCreated: string;
|
||||||
|
twoFactorEnabled: boolean | null;
|
||||||
|
twoFactorSetupRequested: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -138,23 +146,79 @@ export default function UsersTable({ users }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "twoFactorEnabled",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("twoFactor")}
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const userRow = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<span>
|
||||||
|
{userRow.twoFactorEnabled ||
|
||||||
|
userRow.twoFactorSetupRequested ? (
|
||||||
|
<span className="text-green-500">
|
||||||
|
{t("enabled")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>{t("disabled")}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const r = row.original;
|
const r = row.original;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
Open menu
|
||||||
|
</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(r);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<Button
|
<Button
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="ml-2"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelected(r);
|
router.push(`/admin/users/${r.id}`);
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("delete")}
|
{t("edit")}
|
||||||
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
134
src/app/admin/users/[userId]/general/page.tsx
Normal file
134
src/app/admin/users/[userId]/general/page.tsx
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionForm
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
|
export default function GeneralPage() {
|
||||||
|
const { userId } = useParams();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const [loadingData, setLoadingData] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||||||
|
const [userType, setUserType] = useState<UserType | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch current user 2FA status
|
||||||
|
const fetchUserData = async () => {
|
||||||
|
setLoadingData(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/user/${userId}`);
|
||||||
|
if (response.status === 200) {
|
||||||
|
const userData = response.data.data;
|
||||||
|
setTwoFactorEnabled(
|
||||||
|
userData.twoFactorEnabled ||
|
||||||
|
userData.twoFactorSetupRequested
|
||||||
|
);
|
||||||
|
setUserType(userData.type);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch user data:", error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("userErrorDelete"),
|
||||||
|
description: formatAxiosError(error, t("userErrorDelete"))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setLoadingData(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserData();
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const handleTwoFactorToggle = (enabled: boolean) => {
|
||||||
|
setTwoFactorEnabled(enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSettings = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("twoFactorEnabled", twoFactorEnabled);
|
||||||
|
await api.post(`/user/${userId}/2fa`, {
|
||||||
|
twoFactorSetupRequested: twoFactorEnabled
|
||||||
|
});
|
||||||
|
|
||||||
|
setTwoFactorEnabled(twoFactorEnabled);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("otpErrorEnable"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t("otpErrorEnableDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("general")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("userDescription2")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SwitchInput
|
||||||
|
id="two-factor-auth"
|
||||||
|
label={t("otpAuth")}
|
||||||
|
checked={twoFactorEnabled}
|
||||||
|
disabled={userType !== UserType.Internal}
|
||||||
|
onCheckedChange={handleTwoFactorToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleSaveSettings}
|
||||||
|
>
|
||||||
|
{t("targetTlsSubmit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
55
src/app/admin/users/[userId]/layout.tsx
Normal file
55
src/app/admin/users/[userId]/layout.tsx
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { AdminGetUserResponse } from "@server/routers/user/adminGetUser";
|
||||||
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
|
import { cache } from "react";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
interface UserLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ userId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UserLayoutProps(props: UserLayoutProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
let user = null;
|
||||||
|
try {
|
||||||
|
const getUser = cache(async () =>
|
||||||
|
internal.get<AxiosResponse<AdminGetUserResponse>>(
|
||||||
|
`/user/${params.userId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const res = await getUser();
|
||||||
|
user = res.data.data;
|
||||||
|
} catch {
|
||||||
|
redirect(`/admin/users`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
title: t('general'),
|
||||||
|
href: "/admin/users/{userId}/general"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={`${user?.email || user?.name || user?.username}`}
|
||||||
|
description={t('userDescription2')}
|
||||||
|
/>
|
||||||
|
<HorizontalTabs items={navItems}>
|
||||||
|
{children}
|
||||||
|
</HorizontalTabs>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
8
src/app/admin/users/[userId]/page.tsx
Normal file
8
src/app/admin/users/[userId]/page.tsx
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function UserPage(props: {
|
||||||
|
params: Promise<{ userId: string }>;
|
||||||
|
}) {
|
||||||
|
const { userId } = await props.params;
|
||||||
|
redirect(`/admin/users/${userId}/general`);
|
||||||
|
}
|
|
@ -38,7 +38,9 @@ export default async function UsersPage(props: PageProps) {
|
||||||
idpId: row.idpId,
|
idpId: row.idpId,
|
||||||
idpName: row.idpName || t('idpNameInternal'),
|
idpName: row.idpName || t('idpNameInternal'),
|
||||||
dateCreated: row.dateCreated,
|
dateCreated: row.dateCreated,
|
||||||
serverAdmin: row.serverAdmin
|
serverAdmin: row.serverAdmin,
|
||||||
|
twoFactorEnabled: row.twoFactorEnabled,
|
||||||
|
twoFactorSetupRequested: row.twoFactorSetupRequested
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
62
src/app/auth/2fa/setup/page.tsx
Normal file
62
src/app/auth/2fa/setup/page.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import TwoFactorSetupForm from "@app/components/TwoFactorSetupForm";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
|
|
||||||
|
export default function Setup2FAPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const redirect = searchParams?.get("redirect");
|
||||||
|
const email = searchParams?.get("email");
|
||||||
|
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
// Redirect to login if no email is provided
|
||||||
|
useEffect(() => {
|
||||||
|
if (!email) {
|
||||||
|
router.push("/auth/login");
|
||||||
|
}
|
||||||
|
}, [email, router]);
|
||||||
|
|
||||||
|
const handleComplete = () => {
|
||||||
|
console.log("2FA setup complete", redirect, email);
|
||||||
|
if (redirect) {
|
||||||
|
const cleanUrl = cleanRedirect(redirect);
|
||||||
|
console.log("Redirecting to:", cleanUrl);
|
||||||
|
router.push(cleanUrl);
|
||||||
|
} else {
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("otpSetup")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("adminEnabled2FaOnYourAccount", { email: email || "your account" })}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TwoFactorSetupForm
|
||||||
|
email={email || undefined}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
submitButtonText="Continue"
|
||||||
|
showCancelButton={false}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
89
src/components/Enable2FaDialog.tsx
Normal file
89
src/components/Enable2FaDialog.tsx
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import TwoFactorSetupForm from "./TwoFactorSetupForm";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
|
||||||
|
type Enable2FaDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (val: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Enable2FaDialog({ open, setOpen }: Enable2FaDialogProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const formRef = useRef<{ handleSubmit: () => void }>(null);
|
||||||
|
const { user, updateUser } = useUserContext();
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setCurrentStep(1);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (formRef.current) {
|
||||||
|
formRef.current.handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Credenza
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setOpen(val);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t('otpSetup')}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t('otpSetupDescription')}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<TwoFactorSetupForm
|
||||||
|
ref={formRef}
|
||||||
|
isDialog={true}
|
||||||
|
submitButtonText={t('submit')}
|
||||||
|
cancelButtonText="Close"
|
||||||
|
showCancelButton={false}
|
||||||
|
onComplete={() => {setOpen(false); updateUser({ twoFactorEnabled: true });}}
|
||||||
|
onStepChange={setCurrentStep}
|
||||||
|
onLoadingChange={setLoading}
|
||||||
|
/>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
{(currentStep === 1 || currentStep === 2) && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{t('submit')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,46 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import {
|
|
||||||
RequestTotpSecretBody,
|
|
||||||
RequestTotpSecretResponse,
|
|
||||||
VerifyTotpBody,
|
|
||||||
VerifyTotpResponse
|
|
||||||
} from "@server/routers/auth";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from "@app/components/ui/form";
|
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaClose,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "@app/components/Credenza";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
|
||||||
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import { useTranslations } from "next-intl";
|
import Enable2FaDialog from "./Enable2FaDialog";
|
||||||
|
|
||||||
type Enable2FaProps = {
|
type Enable2FaProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
@ -48,261 +9,5 @@ type Enable2FaProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
||||||
const [step, setStep] = useState(1);
|
return <Enable2FaDialog open={open} setOpen={setOpen} />;
|
||||||
const [secretKey, setSecretKey] = useState("");
|
|
||||||
const [secretUri, setSecretUri] = useState("");
|
|
||||||
const [verificationCode, setVerificationCode] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const { user, updateUser } = useUserContext();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const enableSchema = z.object({
|
|
||||||
password: z.string().min(1, { message: t('passwordRequired') })
|
|
||||||
});
|
|
||||||
|
|
||||||
const confirmSchema = z.object({
|
|
||||||
code: z.string().length(6, { message: t('pincodeInvalid') })
|
|
||||||
});
|
|
||||||
|
|
||||||
const enableForm = useForm<z.infer<typeof enableSchema>>({
|
|
||||||
resolver: zodResolver(enableSchema),
|
|
||||||
defaultValues: {
|
|
||||||
password: ""
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const confirmForm = useForm<z.infer<typeof confirmSchema>>({
|
|
||||||
resolver: zodResolver(confirmSchema),
|
|
||||||
defaultValues: {
|
|
||||||
code: ""
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const request2fa = async (values: z.infer<typeof enableSchema>) => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const res = await api
|
|
||||||
.post<AxiosResponse<RequestTotpSecretResponse>>(
|
|
||||||
`/auth/2fa/request`,
|
|
||||||
{
|
|
||||||
password: values.password
|
|
||||||
} as RequestTotpSecretBody
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
title: t('otpErrorEnable'),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t('otpErrorEnableDescription')
|
|
||||||
),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res && res.data.data.secret) {
|
|
||||||
setSecretKey(res.data.data.secret);
|
|
||||||
setSecretUri(res.data.data.uri);
|
|
||||||
setStep(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirm2fa = async (values: z.infer<typeof confirmSchema>) => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const res = await api
|
|
||||||
.post<AxiosResponse<VerifyTotpResponse>>(`/auth/2fa/enable`, {
|
|
||||||
code: values.code
|
|
||||||
} as VerifyTotpBody)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
title: t('otpErrorEnable'),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t('otpErrorEnableDescription')
|
|
||||||
),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res && res.data.data.valid) {
|
|
||||||
setBackupCodes(res.data.data.backupCodes || []);
|
|
||||||
updateUser({ twoFactorEnabled: true });
|
|
||||||
setStep(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerify = () => {
|
|
||||||
if (verificationCode.length !== 6) {
|
|
||||||
setError(t('otpSetupCheckCode'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (verificationCode === "123456") {
|
|
||||||
setSuccess(true);
|
|
||||||
setStep(3);
|
|
||||||
} else {
|
|
||||||
setError(t('otpSetupCheckCodeRetry'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
setLoading(false);
|
|
||||||
setStep(1);
|
|
||||||
setSecretKey("");
|
|
||||||
setSecretUri("");
|
|
||||||
setVerificationCode("");
|
|
||||||
setError("");
|
|
||||||
setSuccess(false);
|
|
||||||
setBackupCodes([]);
|
|
||||||
enableForm.reset();
|
|
||||||
confirmForm.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Credenza
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(val) => {
|
|
||||||
setOpen(val);
|
|
||||||
reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CredenzaContent>
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>
|
|
||||||
{t('otpSetup')}
|
|
||||||
</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t('otpSetupDescription')}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
{step === 1 && (
|
|
||||||
<Form {...enableForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={enableForm.handleSubmit(request2fa)}
|
|
||||||
className="space-y-4"
|
|
||||||
id="form"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={enableForm.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('password')}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p>
|
|
||||||
{t('otpSetupScanQr')}
|
|
||||||
</p>
|
|
||||||
<div className="h-[250px] mx-auto flex items-center justify-center">
|
|
||||||
<QRCodeCanvas value={secretUri} size={200} />
|
|
||||||
</div>
|
|
||||||
<div className="max-w-md mx-auto">
|
|
||||||
<CopyTextBox
|
|
||||||
text={secretUri}
|
|
||||||
wrapText={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form {...confirmForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={confirmForm.handleSubmit(
|
|
||||||
confirm2fa
|
|
||||||
)}
|
|
||||||
className="space-y-4"
|
|
||||||
id="form"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={confirmForm.control}
|
|
||||||
name="code"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t('otpSetupSecretCode')}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="code"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<div className="space-y-4 text-center">
|
|
||||||
<CheckCircle2
|
|
||||||
className="mx-auto text-green-500"
|
|
||||||
size={48}
|
|
||||||
/>
|
|
||||||
<p className="font-semibold text-lg">
|
|
||||||
{t('otpSetupSuccess')}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{t('otpSetupSuccessStoreBackupCodes')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="max-w-md mx-auto">
|
|
||||||
<CopyTextBox text={backupCodes.join("\n")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
{(step === 1 || step === 2) && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
loading={loading}
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => {
|
|
||||||
if (step === 1) {
|
|
||||||
enableForm.handleSubmit(request2fa)();
|
|
||||||
} else {
|
|
||||||
confirmForm.handleSubmit(confirm2fa)();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('submit')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,6 +48,10 @@ export default function LocaleSwitcher() {
|
||||||
{
|
{
|
||||||
value: "zh-CN",
|
value: "zh-CN",
|
||||||
label: "简体中文"
|
label: "简体中文"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "ko-KR",
|
||||||
|
label: "한국어"
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -4,8 +4,8 @@ import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
@ -13,20 +13,20 @@ import {
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from "@/components/ui/card";
|
} from "@app/components/ui/card";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { LoginResponse } from "@server/routers/auth";
|
import { LoginResponse } from "@server/routers/auth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { LockIcon } from "lucide-react";
|
import { LockIcon, FingerprintIcon } from "lucide-react";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import {
|
import {
|
||||||
|
@ -41,6 +41,7 @@ import Image from "next/image";
|
||||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||||
import { Separator } from "./ui/separator";
|
import { Separator } from "./ui/separator";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { startAuthentication } from "@simplewebauthn/browser";
|
||||||
|
|
||||||
export type LoginFormIDP = {
|
export type LoginFormIDP = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
|
@ -65,6 +66,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
const hasIdp = idps && idps.length > 0;
|
const hasIdp = idps && idps.length > 0;
|
||||||
|
|
||||||
const [mfaRequested, setMfaRequested] = useState(false);
|
const [mfaRequested, setMfaRequested] = useState(false);
|
||||||
|
const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
|
@ -94,30 +96,104 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function initiateSecurityKeyAuth() {
|
||||||
|
setShowSecurityKeyPrompt(true);
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start WebAuthn authentication without email
|
||||||
|
const startRes = await api.post("/auth/security-key/authenticate/start", {});
|
||||||
|
|
||||||
|
if (!startRes) {
|
||||||
|
setError(t('securityKeyAuthError', {
|
||||||
|
defaultValue: "Failed to start security key authentication"
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tempSessionId, ...options } = startRes.data.data;
|
||||||
|
|
||||||
|
// Perform WebAuthn authentication
|
||||||
|
try {
|
||||||
|
const credential = await startAuthentication(options);
|
||||||
|
|
||||||
|
// Verify authentication
|
||||||
|
const verifyRes = await api.post(
|
||||||
|
"/auth/security-key/authenticate/verify",
|
||||||
|
{ credential },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Temp-Session-Id': tempSessionId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (verifyRes) {
|
||||||
|
if (onLogin) {
|
||||||
|
await onLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
if (error.message.includes('denied permission')) {
|
||||||
|
setError(t('securityKeyPermissionDenied', {
|
||||||
|
defaultValue: "Please allow access to your security key to continue signing in."
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setError(t('securityKeyRemovedTooQuickly', {
|
||||||
|
defaultValue: "Please keep your security key connected until the sign-in process completes."
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (error.name === 'NotSupportedError') {
|
||||||
|
setError(t('securityKeyNotSupported', {
|
||||||
|
defaultValue: "Your security key may not be compatible. Please try a different security key."
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setError(t('securityKeyUnknownError', {
|
||||||
|
defaultValue: "There was a problem using your security key. Please try again."
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.isAxiosError) {
|
||||||
|
setError(formatAxiosError(e, t('securityKeyAuthError', {
|
||||||
|
defaultValue: "Failed to authenticate with security key"
|
||||||
|
})));
|
||||||
|
} else {
|
||||||
|
console.error(e);
|
||||||
|
setError(e.message || t('securityKeyAuthError', {
|
||||||
|
defaultValue: "Failed to authenticate with security key"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setShowSecurityKeyPrompt(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit(values: any) {
|
async function onSubmit(values: any) {
|
||||||
const { email, password } = form.getValues();
|
const { email, password } = form.getValues();
|
||||||
const { code } = mfaForm.getValues();
|
const { code } = mfaForm.getValues();
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setShowSecurityKeyPrompt(false);
|
||||||
|
|
||||||
const res = await api
|
try {
|
||||||
.post<AxiosResponse<LoginResponse>>("/auth/login", {
|
const res = await api.post<AxiosResponse<LoginResponse>>("/auth/login", {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
code
|
code
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
setError(
|
|
||||||
formatAxiosError(e, t('loginError'))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res) {
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const data = res.data.data;
|
const data = res.data.data;
|
||||||
|
|
||||||
|
if (data?.useSecurityKey) {
|
||||||
|
await initiateSecurityKeyAuth();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (data?.codeRequested) {
|
if (data?.codeRequested) {
|
||||||
setMfaRequested(true);
|
setMfaRequested(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
@ -134,12 +210,32 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data?.twoFactorSetupRequired) {
|
||||||
|
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''}`;
|
||||||
|
router.push(setupUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (onLogin) {
|
if (onLogin) {
|
||||||
await onLogin();
|
await onLogin();
|
||||||
}
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.isAxiosError) {
|
||||||
|
const errorMessage = formatAxiosError(e, t('loginError', {
|
||||||
|
defaultValue: "Failed to log in"
|
||||||
|
}));
|
||||||
|
setError(errorMessage);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.error(e);
|
||||||
|
setError(e.message || t('loginError', {
|
||||||
|
defaultValue: "Failed to log in"
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginWithIdp(idpId: number) {
|
async function loginWithIdp(idpId: number) {
|
||||||
|
@ -167,6 +263,17 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{showSecurityKeyPrompt && (
|
||||||
|
<Alert>
|
||||||
|
<FingerprintIcon className="w-5 h-5 mr-2" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t('securityKeyPrompt', {
|
||||||
|
defaultValue: "Please verify your identity using your security key. Make sure your security key is connected and ready."
|
||||||
|
})}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{!mfaRequested && (
|
{!mfaRequested && (
|
||||||
<>
|
<>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
|
@ -216,6 +323,16 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? t('idpConnectingToProcess', {
|
||||||
|
defaultValue: "Connecting..."
|
||||||
|
}) : t('login', {
|
||||||
|
defaultValue: "Log in"
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
|
@ -250,9 +367,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
pattern={
|
pattern={
|
||||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||||
}
|
}
|
||||||
onChange={(e) => {
|
onChange={(value: string) => {
|
||||||
field.onChange(e);
|
field.onChange(value);
|
||||||
if (e.length === 6) {
|
if (value.length === 6) {
|
||||||
mfaForm.handleSubmit(onSubmit)();
|
mfaForm.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -311,14 +428,17 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
{!mfaRequested && (
|
{!mfaRequested && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
form="form"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
onClick={initiateSecurityKeyAuth}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading}
|
disabled={loading || showSecurityKeyPrompt}
|
||||||
>
|
>
|
||||||
<LockIcon className="w-4 h-4 mr-2" />
|
<FingerprintIcon className="w-4 h-4 mr-2" />
|
||||||
{t('login')}
|
{t('securityKeyLogin', {
|
||||||
|
defaultValue: "Sign in with security key"
|
||||||
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{hasIdp && (
|
{hasIdp && (
|
||||||
|
|
|
@ -23,7 +23,10 @@ function getActionsCategories(root: boolean) {
|
||||||
[t('actionGetOrg')]: "getOrg",
|
[t('actionGetOrg')]: "getOrg",
|
||||||
[t('actionUpdateOrg')]: "updateOrg",
|
[t('actionUpdateOrg')]: "updateOrg",
|
||||||
[t('actionGetOrgUser')]: "getOrgUser",
|
[t('actionGetOrgUser')]: "getOrgUser",
|
||||||
[t('actionListOrgDomains')]: "listOrgDomains",
|
[t('actionInviteUser')]: "inviteUser",
|
||||||
|
[t('actionRemoveUser')]: "removeUser",
|
||||||
|
[t('actionListUsers')]: "listUsers",
|
||||||
|
[t('actionListOrgDomains')]: "listOrgDomains"
|
||||||
},
|
},
|
||||||
|
|
||||||
Site: {
|
Site: {
|
||||||
|
@ -65,16 +68,9 @@ function getActionsCategories(root: boolean) {
|
||||||
[t('actionGetRole')]: "getRole",
|
[t('actionGetRole')]: "getRole",
|
||||||
[t('actionListRole')]: "listRoles",
|
[t('actionListRole')]: "listRoles",
|
||||||
[t('actionUpdateRole')]: "updateRole",
|
[t('actionUpdateRole')]: "updateRole",
|
||||||
[t('actionListAllowedRoleResources')]: "listRoleResources"
|
[t('actionListAllowedRoleResources')]: "listRoleResources",
|
||||||
},
|
|
||||||
|
|
||||||
User: {
|
|
||||||
[t('actionInviteUser')]: "inviteUser",
|
|
||||||
[t('actionRemoveUser')]: "removeUser",
|
|
||||||
[t('actionListUsers')]: "listUsers",
|
|
||||||
[t('actionAddUserRole')]: "addUserRole"
|
[t('actionAddUserRole')]: "addUserRole"
|
||||||
},
|
},
|
||||||
|
|
||||||
"Access Token": {
|
"Access Token": {
|
||||||
[t('actionGenerateAccessToken')]: "generateAccessToken",
|
[t('actionGenerateAccessToken')]: "generateAccessToken",
|
||||||
[t('actionDeleteAccessToken')]: "deleteAcessToken",
|
[t('actionDeleteAccessToken')]: "deleteAcessToken",
|
||||||
|
@ -114,6 +110,11 @@ function getActionsCategories(root: boolean) {
|
||||||
[t('actionListIdpOrgs')]: "listIdpOrgs",
|
[t('actionListIdpOrgs')]: "listIdpOrgs",
|
||||||
[t('actionUpdateIdpOrg')]: "updateIdpOrg"
|
[t('actionUpdateIdpOrg')]: "updateIdpOrg"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
actionsByCategory["User"] = {
|
||||||
|
[t('actionUpdateUser')]: "updateUser",
|
||||||
|
[t('actionGetUser')]: "getUser"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return actionsByCategory;
|
return actionsByCategory;
|
||||||
|
|
|
@ -20,7 +20,8 @@ import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import Disable2FaForm from "./Disable2FaForm";
|
import Disable2FaForm from "./Disable2FaForm";
|
||||||
import Enable2FaForm from "./Enable2FaForm";
|
import SecurityKeyForm from "./SecurityKeyForm";
|
||||||
|
import Enable2FaDialog from "./Enable2FaDialog";
|
||||||
import SupporterStatus from "./SupporterStatus";
|
import SupporterStatus from "./SupporterStatus";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import LocaleSwitcher from "@app/components/LocaleSwitcher";
|
import LocaleSwitcher from "@app/components/LocaleSwitcher";
|
||||||
|
@ -39,6 +40,7 @@ export default function ProfileIcon() {
|
||||||
|
|
||||||
const [openEnable2fa, setOpenEnable2fa] = useState(false);
|
const [openEnable2fa, setOpenEnable2fa] = useState(false);
|
||||||
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
||||||
|
const [openSecurityKey, setOpenSecurityKey] = useState(false);
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
|
@ -70,8 +72,12 @@ export default function ProfileIcon() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
<Enable2FaDialog open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
||||||
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
|
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
|
||||||
|
<SecurityKeyForm
|
||||||
|
open={openSecurityKey}
|
||||||
|
setOpen={setOpenSecurityKey}
|
||||||
|
/>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
@ -105,6 +111,31 @@ export default function ProfileIcon() {
|
||||||
)}
|
)}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
{user?.type === UserType.Internal && (
|
||||||
|
<>
|
||||||
|
{!user.twoFactorEnabled && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setOpenEnable2fa(true)}
|
||||||
|
>
|
||||||
|
<span>{t("otpEnable")}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{user.twoFactorEnabled && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setOpenDisable2fa(true)}
|
||||||
|
>
|
||||||
|
<span>{t("otpDisable")}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setOpenSecurityKey(true)}
|
||||||
|
>
|
||||||
|
<span>{t("securityKeyManage")}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
{user?.type === UserType.Internal && (
|
{user?.type === UserType.Internal && (
|
||||||
<>
|
<>
|
||||||
{!user.twoFactorEnabled && (
|
{!user.twoFactorEnabled && (
|
||||||
|
|
859
src/components/SecurityKeyForm.tsx
Normal file
859
src/components/SecurityKeyForm.tsx
Normal file
|
@ -0,0 +1,859 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import { startRegistration } from "@simplewebauthn/browser";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { Card, CardContent } from "@app/components/ui/card";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { Loader2, KeyRound, Trash2, Plus, Shield, Info } from "lucide-react";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
|
type SecurityKeyFormProps = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SecurityKey = {
|
||||||
|
credentialId: string;
|
||||||
|
name: string;
|
||||||
|
lastUsed: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeleteSecurityKeyData = {
|
||||||
|
credentialId: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RegisterFormValues = {
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeleteFormValues = {
|
||||||
|
password: string;
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FieldProps = {
|
||||||
|
field: {
|
||||||
|
value: string;
|
||||||
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
name: string;
|
||||||
|
ref: React.Ref<HTMLInputElement>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SecurityKeyForm({
|
||||||
|
open,
|
||||||
|
setOpen
|
||||||
|
}: SecurityKeyFormProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const [securityKeys, setSecurityKeys] = useState<SecurityKey[]>([]);
|
||||||
|
const [isRegistering, setIsRegistering] = useState(false);
|
||||||
|
const [dialogState, setDialogState] = useState<
|
||||||
|
"list" | "register" | "register2fa" | "delete" | "delete2fa"
|
||||||
|
>("list");
|
||||||
|
const [selectedSecurityKey, setSelectedSecurityKey] =
|
||||||
|
useState<DeleteSecurityKeyData | null>(null);
|
||||||
|
const [deleteInProgress, setDeleteInProgress] = useState(false);
|
||||||
|
const [pendingDeleteCredentialId, setPendingDeleteCredentialId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [pendingDeletePassword, setPendingDeletePassword] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [pendingRegisterData, setPendingRegisterData] = useState<{
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [register2FAForm, setRegister2FAForm] = useState<{ code: string }>({
|
||||||
|
code: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSecurityKeys();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: t("securityKeyNameRequired") }),
|
||||||
|
password: z.string().min(1, { message: t("passwordRequired") }),
|
||||||
|
code: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSchema = z.object({
|
||||||
|
password: z.string().min(1, { message: t("passwordRequired") }),
|
||||||
|
code: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerForm = useForm<RegisterFormValues>({
|
||||||
|
resolver: zodResolver(registerSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
password: "",
|
||||||
|
code: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteForm = useForm<DeleteFormValues>({
|
||||||
|
resolver: zodResolver(deleteSchema),
|
||||||
|
defaultValues: {
|
||||||
|
password: "",
|
||||||
|
code: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadSecurityKeys = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get("/auth/security-key/list");
|
||||||
|
setSecurityKeys(response.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: formatAxiosError(error, t("securityKeyLoadError"))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegisterSecurityKey = async (values: RegisterFormValues) => {
|
||||||
|
try {
|
||||||
|
// Check browser compatibility first
|
||||||
|
if (!window.PublicKeyCredential) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: t("securityKeyBrowserNotSupported", {
|
||||||
|
defaultValue:
|
||||||
|
"Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRegistering(true);
|
||||||
|
const startRes = await api.post(
|
||||||
|
"/auth/security-key/register/start",
|
||||||
|
{
|
||||||
|
name: values.name,
|
||||||
|
password: values.password,
|
||||||
|
code: values.code
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// If 2FA is required
|
||||||
|
if (startRes.status === 202 && startRes.data.data?.codeRequested) {
|
||||||
|
setPendingRegisterData({
|
||||||
|
name: values.name,
|
||||||
|
password: values.password
|
||||||
|
});
|
||||||
|
setDialogState("register2fa");
|
||||||
|
setIsRegistering(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = startRes.data.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = await startRegistration(options);
|
||||||
|
|
||||||
|
await api.post("/auth/security-key/register/verify", {
|
||||||
|
credential
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: t("securityKeyRegisterSuccess", {
|
||||||
|
defaultValue: "Security key registered successfully"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
registerForm.reset();
|
||||||
|
setDialogState("list");
|
||||||
|
await loadSecurityKeys();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === "NotAllowedError") {
|
||||||
|
if (error.message.includes("denied permission")) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: t("securityKeyPermissionDenied", {
|
||||||
|
defaultValue:
|
||||||
|
"Please allow access to your security key to continue registration."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: t("securityKeyRemovedTooQuickly", {
|
||||||
|
defaultValue:
|
||||||
|
"Please keep your security key connected until the registration process completes."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (error.name === "NotSupportedError") {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: t("securityKeyNotSupported", {
|
||||||
|
defaultValue:
|
||||||
|
"Your security key may not be compatible. Please try a different security key."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: t("securityKeyUnknownError", {
|
||||||
|
defaultValue:
|
||||||
|
"There was a problem registering your security key. Please try again."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error; // Re-throw to be caught by outer catch
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Security key registration error:", error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t("securityKeyRegisterError", {
|
||||||
|
defaultValue: "Failed to register security key"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRegistering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSecurityKey = async (values: DeleteFormValues) => {
|
||||||
|
if (!selectedSecurityKey) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeleteInProgress(true);
|
||||||
|
const encodedCredentialId = encodeURIComponent(
|
||||||
|
selectedSecurityKey.credentialId
|
||||||
|
);
|
||||||
|
const response = await api.delete(
|
||||||
|
`/auth/security-key/${encodedCredentialId}`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
password: values.password,
|
||||||
|
code: values.code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// If 2FA is required
|
||||||
|
if (response.status === 202 && response.data.data.codeRequested) {
|
||||||
|
setPendingDeleteCredentialId(encodedCredentialId);
|
||||||
|
setPendingDeletePassword(values.password);
|
||||||
|
setDialogState("delete2fa");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: t("securityKeyRemoveSuccess")
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteForm.reset();
|
||||||
|
setSelectedSecurityKey(null);
|
||||||
|
setDialogState("list");
|
||||||
|
await loadSecurityKeys();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t("securityKeyRemoveError")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleteInProgress(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handle2FASubmit = async (values: DeleteFormValues) => {
|
||||||
|
if (!pendingDeleteCredentialId || !pendingDeletePassword) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeleteInProgress(true);
|
||||||
|
await api.delete(
|
||||||
|
`/auth/security-key/${pendingDeleteCredentialId}`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
password: pendingDeletePassword,
|
||||||
|
code: values.code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: t("securityKeyRemoveSuccess")
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteForm.reset();
|
||||||
|
setSelectedSecurityKey(null);
|
||||||
|
setDialogState("list");
|
||||||
|
setPendingDeleteCredentialId(null);
|
||||||
|
setPendingDeletePassword(null);
|
||||||
|
await loadSecurityKeys();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t("securityKeyRemoveError")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleteInProgress(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegister2FASubmit = async (values: { code: string }) => {
|
||||||
|
if (!pendingRegisterData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsRegistering(true);
|
||||||
|
const startRes = await api.post(
|
||||||
|
"/auth/security-key/register/start",
|
||||||
|
{
|
||||||
|
name: pendingRegisterData.name,
|
||||||
|
password: pendingRegisterData.password,
|
||||||
|
code: values.code
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = startRes.data.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = await startRegistration(options);
|
||||||
|
|
||||||
|
await api.post("/auth/security-key/register/verify", {
|
||||||
|
credential
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: t("securityKeyRegisterSuccess", {
|
||||||
|
defaultValue: "Security key registered successfully"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
registerForm.reset();
|
||||||
|
setDialogState("list");
|
||||||
|
setPendingRegisterData(null);
|
||||||
|
setRegister2FAForm({ code: "" });
|
||||||
|
await loadSecurityKeys();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === "NotAllowedError") {
|
||||||
|
if (error.message.includes("denied permission")) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: t("securityKeyPermissionDenied", {
|
||||||
|
defaultValue:
|
||||||
|
"Please allow access to your security key to continue registration."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: t("securityKeyRemovedTooQuickly", {
|
||||||
|
defaultValue:
|
||||||
|
"Please keep your security key connected until the registration process completes."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (error.name === "NotSupportedError") {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: t("securityKeyNotSupported", {
|
||||||
|
defaultValue:
|
||||||
|
"Your security key may not be compatible. Please try a different security key."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: t("securityKeyUnknownError", {
|
||||||
|
defaultValue:
|
||||||
|
"There was a problem registering your security key. Please try again."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error; // Re-throw to be caught by outer catch
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Security key registration error:", error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t("securityKeyRegisterError", {
|
||||||
|
defaultValue: "Failed to register security key"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
});
|
||||||
|
setRegister2FAForm({ code: "" });
|
||||||
|
} finally {
|
||||||
|
setIsRegistering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenChange = (open: boolean) => {
|
||||||
|
if (open) {
|
||||||
|
loadSecurityKeys();
|
||||||
|
} else {
|
||||||
|
registerForm.reset();
|
||||||
|
deleteForm.reset();
|
||||||
|
setSelectedSecurityKey(null);
|
||||||
|
setDialogState("list");
|
||||||
|
setPendingRegisterData(null);
|
||||||
|
setRegister2FAForm({ code: "" });
|
||||||
|
}
|
||||||
|
setOpen(open);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||||
|
<CredenzaContent>
|
||||||
|
{dialogState === "list" && (
|
||||||
|
<>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle className="flex items-center gap-2">
|
||||||
|
{t("securityKeyManage")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("securityKeyDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("securityKeyList")}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
setDialogState("register")
|
||||||
|
}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("securityKeyAdd")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{securityKeys.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{securityKeys.map((securityKey) => (
|
||||||
|
<Card
|
||||||
|
key={
|
||||||
|
securityKey.credentialId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary">
|
||||||
|
<KeyRound className="h-4 w-4 text-secondary-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{
|
||||||
|
securityKey.name
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"securityKeyLastUsed",
|
||||||
|
{
|
||||||
|
date: new Date(
|
||||||
|
securityKey.lastUsed
|
||||||
|
).toLocaleDateString()
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="h-8 w-8 p-0 text-white hover:text-white/80"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSecurityKey(
|
||||||
|
{
|
||||||
|
credentialId:
|
||||||
|
securityKey.credentialId,
|
||||||
|
name: securityKey.name
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setDialogState(
|
||||||
|
"delete"
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Shield className="mb-2 h-12 w-12 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("securityKeyNoKeysRegistered")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("securityKeyNoKeysDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{securityKeys.length === 1 && (
|
||||||
|
<Alert variant="default">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t("securityKeyRecommendation")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CredenzaBody>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialogState === "register" && (
|
||||||
|
<>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("securityKeyRegisterTitle")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("securityKeyRegisterDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...registerForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={registerForm.handleSubmit(
|
||||||
|
handleRegisterSecurityKey
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={registerForm.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }: FieldProps) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"securityKeyNameLabel"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder={t(
|
||||||
|
"securityKeyNamePlaceholder"
|
||||||
|
)}
|
||||||
|
disabled={
|
||||||
|
isRegistering
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={registerForm.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }: FieldProps) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("password")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
disabled={
|
||||||
|
isRegistering
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
registerForm.reset();
|
||||||
|
setDialogState("list");
|
||||||
|
}}
|
||||||
|
disabled={isRegistering}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="form"
|
||||||
|
disabled={isRegistering}
|
||||||
|
className={cn(
|
||||||
|
"min-w-[100px]",
|
||||||
|
isRegistering &&
|
||||||
|
"cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
loading={isRegistering}
|
||||||
|
>
|
||||||
|
{t("securityKeyRegister")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialogState === "register2fa" && (
|
||||||
|
<>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("securityKeyTwoFactorRequired")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("securityKeyTwoFactorDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
{t("securityKeyTwoFactorCode")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={register2FAForm.code}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRegister2FAForm({
|
||||||
|
code: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
maxLength={6}
|
||||||
|
disabled={isRegistering}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setRegister2FAForm({ code: "" });
|
||||||
|
setDialogState("list");
|
||||||
|
setPendingRegisterData(null);
|
||||||
|
}}
|
||||||
|
disabled={isRegistering}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="min-w-[100px]"
|
||||||
|
disabled={
|
||||||
|
isRegistering ||
|
||||||
|
register2FAForm.code.length !== 6
|
||||||
|
}
|
||||||
|
loading={isRegistering}
|
||||||
|
onClick={() =>
|
||||||
|
handleRegister2FASubmit({
|
||||||
|
code: register2FAForm.code
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("securityKeyRegister")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialogState === "delete" && (
|
||||||
|
<>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle className="flex items-center gap-2">
|
||||||
|
{t("securityKeyRemoveTitle")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("securityKeyRemoveDescription", { name: selectedSecurityKey!.name! })}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...deleteForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={deleteForm.handleSubmit(
|
||||||
|
handleDeleteSecurityKey
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="delete-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={deleteForm.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("password")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
disabled={
|
||||||
|
deleteInProgress
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
deleteForm.reset();
|
||||||
|
setSelectedSecurityKey(null);
|
||||||
|
setDialogState("list");
|
||||||
|
}}
|
||||||
|
disabled={deleteInProgress}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="delete-form"
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={deleteInProgress}
|
||||||
|
loading={deleteInProgress}
|
||||||
|
>
|
||||||
|
{t("securityKeyRemove")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialogState === "delete2fa" && (
|
||||||
|
<>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("securityKeyTwoFactorRequired")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("securityKeyTwoFactorRemoveDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...deleteForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={deleteForm.handleSubmit(
|
||||||
|
handle2FASubmit
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="delete2fa-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={deleteForm.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("securityKeyTwoFactorCode")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
maxLength={6}
|
||||||
|
disabled={
|
||||||
|
deleteInProgress
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
deleteForm.reset();
|
||||||
|
setDialogState("list");
|
||||||
|
setPendingDeleteCredentialId(null);
|
||||||
|
setPendingDeletePassword(null);
|
||||||
|
}}
|
||||||
|
disabled={deleteInProgress}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="delete2fa-form"
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={deleteInProgress}
|
||||||
|
loading={deleteInProgress}
|
||||||
|
>
|
||||||
|
{t("securityKeyRemove")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ interface SwitchComponentProps {
|
||||||
id: string;
|
id: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
checked?: boolean;
|
||||||
defaultChecked?: boolean;
|
defaultChecked?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onCheckedChange: (checked: boolean) => void;
|
onCheckedChange: (checked: boolean) => void;
|
||||||
|
@ -16,6 +17,7 @@ export function SwitchInput({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
disabled,
|
disabled,
|
||||||
|
checked,
|
||||||
defaultChecked = false,
|
defaultChecked = false,
|
||||||
onCheckedChange
|
onCheckedChange
|
||||||
}: SwitchComponentProps) {
|
}: SwitchComponentProps) {
|
||||||
|
@ -24,6 +26,7 @@ export function SwitchInput({
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
<Switch
|
<Switch
|
||||||
id={id}
|
id={id}
|
||||||
|
checked={checked}
|
||||||
defaultChecked={defaultChecked}
|
defaultChecked={defaultChecked}
|
||||||
onCheckedChange={onCheckedChange}
|
onCheckedChange={onCheckedChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
327
src/components/TwoFactorSetupForm.tsx
Normal file
327
src/components/TwoFactorSetupForm.tsx
Normal file
|
@ -0,0 +1,327 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, forwardRef, useImperativeHandle, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import {
|
||||||
|
LoginResponse,
|
||||||
|
RequestTotpSecretBody,
|
||||||
|
RequestTotpSecretResponse,
|
||||||
|
VerifyTotpBody,
|
||||||
|
VerifyTotpResponse
|
||||||
|
} from "@server/routers/auth";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
import { QRCodeCanvas } from "qrcode.react";
|
||||||
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
type TwoFactorSetupFormProps = {
|
||||||
|
onComplete?: (email: string, password: string) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
isDialog?: boolean;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
submitButtonText?: string;
|
||||||
|
cancelButtonText?: string;
|
||||||
|
showCancelButton?: boolean;
|
||||||
|
onStepChange?: (step: number) => void;
|
||||||
|
onLoadingChange?: (loading: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TwoFactorSetupForm = forwardRef<
|
||||||
|
{ handleSubmit: () => void },
|
||||||
|
TwoFactorSetupFormProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isDialog = false,
|
||||||
|
email,
|
||||||
|
password: initialPassword,
|
||||||
|
submitButtonText,
|
||||||
|
cancelButtonText,
|
||||||
|
showCancelButton = false,
|
||||||
|
onStepChange,
|
||||||
|
onLoadingChange
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [secretKey, setSecretKey] = useState("");
|
||||||
|
const [secretUri, setSecretUri] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
// Notify parent of step and loading changes
|
||||||
|
useEffect(() => {
|
||||||
|
onStepChange?.(step);
|
||||||
|
}, [step, onStepChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onLoadingChange?.(loading);
|
||||||
|
}, [loading, onLoadingChange]);
|
||||||
|
|
||||||
|
const enableSchema = z.object({
|
||||||
|
password: z.string().min(1, { message: t("passwordRequired") })
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmSchema = z.object({
|
||||||
|
code: z.string().length(6, { message: t("pincodeInvalid") })
|
||||||
|
});
|
||||||
|
|
||||||
|
const enableForm = useForm<z.infer<typeof enableSchema>>({
|
||||||
|
resolver: zodResolver(enableSchema),
|
||||||
|
defaultValues: {
|
||||||
|
password: initialPassword || ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmForm = useForm<z.infer<typeof confirmSchema>>({
|
||||||
|
resolver: zodResolver(confirmSchema),
|
||||||
|
defaultValues: {
|
||||||
|
code: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const request2fa = async (values: z.infer<typeof enableSchema>) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const endpoint = `/auth/2fa/request`;
|
||||||
|
const payload = { email, password: values.password };
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.post<
|
||||||
|
AxiosResponse<RequestTotpSecretResponse>
|
||||||
|
>(endpoint, payload)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
title: t("otpErrorEnable"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("otpErrorEnableDescription")
|
||||||
|
),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.data.data.secret) {
|
||||||
|
setSecretKey(res.data.data.secret);
|
||||||
|
setSecretUri(res.data.data.uri);
|
||||||
|
setStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm2fa = async (values: z.infer<typeof confirmSchema>) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const endpoint = `/auth/2fa/enable`;
|
||||||
|
const payload = {
|
||||||
|
email,
|
||||||
|
password: enableForm.getValues().password,
|
||||||
|
code: values.code
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.post<AxiosResponse<VerifyTotpResponse>>(endpoint, payload)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
title: t("otpErrorEnable"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("otpErrorEnableDescription")
|
||||||
|
),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.data.data.valid) {
|
||||||
|
setBackupCodes(res.data.data.backupCodes || []);
|
||||||
|
await api
|
||||||
|
.post<AxiosResponse<LoginResponse>>("/auth/login", {
|
||||||
|
email,
|
||||||
|
password: enableForm.getValues().password,
|
||||||
|
code: values.code
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
setStep(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (step === 1) {
|
||||||
|
enableForm.handleSubmit(request2fa)();
|
||||||
|
} else if (step === 2) {
|
||||||
|
confirmForm.handleSubmit(confirm2fa)();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = (email: string, password: string) => {
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete(email, password);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
handleSubmit
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{step === 1 && (
|
||||||
|
<Form {...enableForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={enableForm.handleSubmit(request2fa)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={enableForm.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("password")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>{t("otpSetupScanQr")}</p>
|
||||||
|
<div className="h-[250px] mx-auto flex items-center justify-center">
|
||||||
|
<QRCodeCanvas value={secretUri} size={200} />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<CopyTextBox text={secretUri} wrapText={false} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...confirmForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={confirmForm.handleSubmit(confirm2fa)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={confirmForm.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("otpSetupSecretCode")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="code" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<CheckCircle2
|
||||||
|
className="mx-auto text-green-500"
|
||||||
|
size={48}
|
||||||
|
/>
|
||||||
|
<p className="font-semibold text-lg">
|
||||||
|
{t("otpSetupSuccess")}
|
||||||
|
</p>
|
||||||
|
<p>{t("otpSetupSuccessStoreBackupCodes")}</p>
|
||||||
|
|
||||||
|
{backupCodes.length > 0 && (
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<CopyTextBox text={backupCodes.join("\n")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons - only show when not in dialog */}
|
||||||
|
{!isDialog && (
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
{showCancelButton && onCancel && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{cancelButtonText || "Cancel"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(step === 1 || step === 2) && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{submitButtonText || t("submit")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 3 && (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
handleComplete(
|
||||||
|
email!,
|
||||||
|
enableForm.getValues().password
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t("continueToApplication")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default TwoFactorSetupForm;
|
|
@ -1,4 +1,4 @@
|
||||||
export type Locale = (typeof locales)[number];
|
export type Locale = (typeof locales)[number];
|
||||||
|
|
||||||
export const locales = ['en-US', 'es-ES', 'fr-FR', 'de-DE', 'nl-NL', 'it-IT', 'pl-PL', 'pt-PT', 'tr-TR', 'zh-CN'] as const;
|
export const locales = ['en-US', 'es-ES', 'fr-FR', 'de-DE', 'nl-NL', 'it-IT', 'pl-PL', 'pt-PT', 'tr-TR', 'zh-CN', 'ko-KR'] as const;
|
||||||
export const defaultLocale: Locale = 'en-US';
|
export const defaultLocale: Locale = 'en-US';
|
Loading…
Add table
Add a link
Reference in a new issue