mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-16 10:06:42 +03:00
Compare commits
15 Commits
28496501a7
...
265a0f304f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
265a0f304f | ||
|
|
dfd837cfb0 | ||
|
|
152e585206 | ||
|
|
acb17a7b50 | ||
|
|
7933a83a42 | ||
|
|
2eef58aa1d | ||
|
|
6659b7cb0f | ||
|
|
f134796913 | ||
|
|
b4abbd7d3b | ||
|
|
438df7d484 | ||
|
|
e27a94ae0b | ||
|
|
1dee548dbc | ||
|
|
91e17e12b7 | ||
|
|
bb45483e9e | ||
|
|
7b4eaf2d10 |
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@ -224,3 +224,29 @@ jobs:
|
|||||||
sources: |
|
sources: |
|
||||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-amd64
|
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-amd64
|
||||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-rpi
|
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-rpi
|
||||||
|
axera_build:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
name: AXERA Build
|
||||||
|
needs:
|
||||||
|
- amd64_build
|
||||||
|
- arm64_build
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up QEMU and Buildx
|
||||||
|
id: setup
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Build and push Axera build
|
||||||
|
uses: docker/bake-action@v6
|
||||||
|
with:
|
||||||
|
source: .
|
||||||
|
push: true
|
||||||
|
targets: axcl
|
||||||
|
files: docker/axcl/axcl.hcl
|
||||||
|
set: |
|
||||||
|
axcl.tags=${{ steps.setup.outputs.image-name }}-axcl
|
||||||
|
*.cache-from=type=gha
|
||||||
55
docker/axcl/Dockerfile
Normal file
55
docker/axcl/Dockerfile
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# syntax=docker/dockerfile:1.6
|
||||||
|
|
||||||
|
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Globally set pip break-system-packages option to avoid having to specify it every time
|
||||||
|
ARG PIP_BREAK_SYSTEM_PACKAGES=1
|
||||||
|
|
||||||
|
|
||||||
|
FROM frigate AS frigate-axcl
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG PIP_BREAK_SYSTEM_PACKAGES
|
||||||
|
|
||||||
|
# Install axpyengine
|
||||||
|
RUN wget https://github.com/AXERA-TECH/pyaxengine/releases/download/0.1.3.rc1/axengine-0.1.3-py3-none-any.whl -O /axengine-0.1.3-py3-none-any.whl
|
||||||
|
RUN pip3 install -i https://mirrors.aliyun.com/pypi/simple/ /axengine-0.1.3-py3-none-any.whl \
|
||||||
|
&& rm /axengine-0.1.3-py3-none-any.whl
|
||||||
|
|
||||||
|
# Install axcl
|
||||||
|
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
|
echo "Installing x86_64 version of axcl"; \
|
||||||
|
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/axcl_host_x86_64_V3.6.5_20250908154509_NO4973.deb -O /axcl.deb; \
|
||||||
|
else \
|
||||||
|
echo "Installing aarch64 version of axcl"; \
|
||||||
|
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/axcl_host_aarch64_V3.6.5_20250908154509_NO4973.deb -O /axcl.deb; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN mkdir /unpack_axcl && \
|
||||||
|
dpkg-deb -x /axcl.deb /unpack_axcl && \
|
||||||
|
cp -R /unpack_axcl/usr/bin/axcl /usr/bin/ && \
|
||||||
|
cp -R /unpack_axcl/usr/lib/axcl /usr/lib/ && \
|
||||||
|
rm -rf /unpack_axcl /axcl.deb
|
||||||
|
|
||||||
|
|
||||||
|
# Install axcl ffmpeg
|
||||||
|
RUN mkdir -p /usr/lib/ffmpeg/axcl
|
||||||
|
|
||||||
|
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
|
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/ffmpeg-x64 -O /usr/lib/ffmpeg/axcl/ffmpeg && \
|
||||||
|
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/ffprobe-x64 -O /usr/lib/ffmpeg/axcl/ffprobe; \
|
||||||
|
else \
|
||||||
|
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/ffmpeg-aarch64 -O /usr/lib/ffmpeg/axcl/ffmpeg && \
|
||||||
|
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/ffprobe-aarch64 -O /usr/lib/ffmpeg/axcl/ffprobe; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN chmod +x /usr/lib/ffmpeg/axcl/ffmpeg /usr/lib/ffmpeg/axcl/ffprobe
|
||||||
|
|
||||||
|
# Set ldconfig path
|
||||||
|
RUN echo "/usr/lib/axcl" > /etc/ld.so.conf.d/ax.conf
|
||||||
|
|
||||||
|
# Set env
|
||||||
|
ENV PATH="$PATH:/usr/bin/axcl"
|
||||||
|
ENV LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/axcl"
|
||||||
|
|
||||||
|
ENTRYPOINT ["sh", "-c", "ldconfig && exec /init"]
|
||||||
13
docker/axcl/axcl.hcl
Normal file
13
docker/axcl/axcl.hcl
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
target frigate {
|
||||||
|
dockerfile = "docker/main/Dockerfile"
|
||||||
|
platforms = ["linux/amd64", "linux/arm64"]
|
||||||
|
target = "frigate"
|
||||||
|
}
|
||||||
|
|
||||||
|
target axcl {
|
||||||
|
dockerfile = "docker/axcl/Dockerfile"
|
||||||
|
contexts = {
|
||||||
|
frigate = "target:frigate",
|
||||||
|
}
|
||||||
|
platforms = ["linux/amd64", "linux/arm64"]
|
||||||
|
}
|
||||||
15
docker/axcl/axcl.mk
Normal file
15
docker/axcl/axcl.mk
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
BOARDS += axcl
|
||||||
|
|
||||||
|
local-axcl: version
|
||||||
|
docker buildx bake --file=docker/axcl/axcl.hcl axcl \
|
||||||
|
--set axcl.tags=frigate:latest-axcl \
|
||||||
|
--load
|
||||||
|
|
||||||
|
build-axcl: version
|
||||||
|
docker buildx bake --file=docker/axcl/axcl.hcl axcl \
|
||||||
|
--set axcl.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-axcl
|
||||||
|
|
||||||
|
push-axcl: build-axcl
|
||||||
|
docker buildx bake --file=docker/axcl/axcl.hcl axcl \
|
||||||
|
--set axcl.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-axcl \
|
||||||
|
--push
|
||||||
83
docker/axcl/user_installation.sh
Executable file
83
docker/axcl/user_installation.sh
Executable file
@ -0,0 +1,83 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Update package list and install dependencies
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y build-essential cmake git wget pciutils kmod udev
|
||||||
|
|
||||||
|
# Check if gcc-12 is needed
|
||||||
|
current_gcc_version=$(gcc --version | head -n1 | awk '{print $NF}')
|
||||||
|
gcc_major_version=$(echo $current_gcc_version | cut -d'.' -f1)
|
||||||
|
|
||||||
|
if [[ $gcc_major_version -lt 12 ]]; then
|
||||||
|
echo "Current GCC version ($current_gcc_version) is lower than 12, installing gcc-12..."
|
||||||
|
sudo apt-get install -y gcc-12
|
||||||
|
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-12 12
|
||||||
|
echo "GCC-12 installed and set as default"
|
||||||
|
else
|
||||||
|
echo "Current GCC version ($current_gcc_version) is sufficient, skipping GCC installation"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine architecture
|
||||||
|
arch=$(uname -m)
|
||||||
|
download_url=""
|
||||||
|
|
||||||
|
if [[ $arch == "x86_64" ]]; then
|
||||||
|
download_url="https://github.com/ivanshi1108/assets/releases/download/v0.16.2/axcl_host_x86_64_V3.6.5_20250908154509_NO4973.deb"
|
||||||
|
deb_file="axcl_host_x86_64_V3.6.5_20250908154509_NO4973.deb"
|
||||||
|
elif [[ $arch == "aarch64" ]]; then
|
||||||
|
download_url="https://github.com/ivanshi1108/assets/releases/download/v0.16.2/axcl_host_aarch64_V3.6.5_20250908154509_NO4973.deb"
|
||||||
|
deb_file="axcl_host_aarch64_V3.6.5_20250908154509_NO4973.deb"
|
||||||
|
else
|
||||||
|
echo "Unsupported architecture: $arch"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download AXCL driver
|
||||||
|
echo "Downloading AXCL driver for $arch..."
|
||||||
|
wget "$download_url" -O "$deb_file"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Failed to download AXCL driver"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install AXCL driver
|
||||||
|
echo "Installing AXCL driver..."
|
||||||
|
sudo dpkg -i "$deb_file"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Failed to install AXCL driver, attempting to fix dependencies..."
|
||||||
|
sudo apt-get install -f -y
|
||||||
|
sudo dpkg -i "$deb_file"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "AXCL driver installation failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update environment
|
||||||
|
echo "Updating environment..."
|
||||||
|
source /etc/profile
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
echo "Verifying AXCL installation..."
|
||||||
|
if command -v axcl-smi &> /dev/null; then
|
||||||
|
echo "AXCL driver detected, checking AI accelerator status..."
|
||||||
|
|
||||||
|
axcl_output=$(axcl-smi 2>&1)
|
||||||
|
axcl_exit_code=$?
|
||||||
|
|
||||||
|
echo "$axcl_output"
|
||||||
|
|
||||||
|
if [ $axcl_exit_code -eq 0 ]; then
|
||||||
|
echo "AXCL driver installation completed successfully!"
|
||||||
|
else
|
||||||
|
echo "AXCL driver installed but no AI accelerator detected or communication failed."
|
||||||
|
echo "Please check if the AI accelerator is properly connected and powered on."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "axcl-smi command not found. AXCL driver installation may have failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@ -49,6 +49,11 @@ Frigate supports multiple different detectors that work on different types of ha
|
|||||||
|
|
||||||
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs.
|
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs.
|
||||||
|
|
||||||
|
**AXERA** <CommunityBadge />
|
||||||
|
|
||||||
|
- [AXEngine](#axera): axmodels can run on AXERA AI acceleration.
|
||||||
|
|
||||||
|
|
||||||
**For Testing**
|
**For Testing**
|
||||||
|
|
||||||
- [CPU Detector (not recommended for actual use](#cpu-detector-not-recommended): Use a CPU to run tflite model, this is not recommended and in most cases OpenVINO can be used in CPU mode with better results.
|
- [CPU Detector (not recommended for actual use](#cpu-detector-not-recommended): Use a CPU to run tflite model, this is not recommended and in most cases OpenVINO can be used in CPU mode with better results.
|
||||||
@ -1476,6 +1481,41 @@ model:
|
|||||||
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
|
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## AXERA
|
||||||
|
|
||||||
|
Hardware accelerated object detection is supported on the following SoCs:
|
||||||
|
|
||||||
|
- AX650N
|
||||||
|
- AX8850N
|
||||||
|
|
||||||
|
This implementation uses the [AXera Pulsar2 Toolchain](https://huggingface.co/AXERA-TECH/Pulsar2).
|
||||||
|
|
||||||
|
See the [installation docs](../frigate/installation.md#axera) for information on configuring the AXEngine hardware.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
When configuring the AXEngine detector, you have to specify the model name.
|
||||||
|
|
||||||
|
#### yolov9
|
||||||
|
|
||||||
|
A yolov9 model is provided in the container at /axmodels and is used by this detector type by default.
|
||||||
|
|
||||||
|
Use the model configuration shown below when using the axengine detector with the default axmodel:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
detectors:
|
||||||
|
axengine:
|
||||||
|
type: axengine
|
||||||
|
|
||||||
|
model:
|
||||||
|
path: frigate-yolov9-tiny
|
||||||
|
model_type: yolo-generic
|
||||||
|
width: 320
|
||||||
|
height: 320
|
||||||
|
tensor_format: bgr
|
||||||
|
labelmap_path: /labelmap/coco-80.txt
|
||||||
|
```
|
||||||
|
|
||||||
# Models
|
# Models
|
||||||
|
|
||||||
Some model types are not included in Frigate by default.
|
Some model types are not included in Frigate by default.
|
||||||
|
|||||||
@ -123,7 +123,7 @@ auth:
|
|||||||
# Optional: Refresh time in seconds (default: shown below)
|
# Optional: Refresh time in seconds (default: shown below)
|
||||||
# When the session is going to expire in less time than this setting,
|
# When the session is going to expire in less time than this setting,
|
||||||
# it will be refreshed back to the session_length.
|
# it will be refreshed back to the session_length.
|
||||||
refresh_time: 43200 # 12 hours
|
refresh_time: 1800 # 30 minutes
|
||||||
# Optional: Rate limiting for login failures to help prevent brute force
|
# Optional: Rate limiting for login failures to help prevent brute force
|
||||||
# login attacks (default: shown below)
|
# login attacks (default: shown below)
|
||||||
# See the docs for more information on valid values
|
# See the docs for more information on valid values
|
||||||
|
|||||||
@ -104,6 +104,10 @@ Frigate supports multiple different detectors that work on different types of ha
|
|||||||
|
|
||||||
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs to provide efficient object detection.
|
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs to provide efficient object detection.
|
||||||
|
|
||||||
|
**AXERA** <CommunityBadge />
|
||||||
|
|
||||||
|
- [AXEngine](#axera): axera models can run on AXERA NPUs via AXEngine, delivering highly efficient object detection.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Hailo-8
|
### Hailo-8
|
||||||
@ -287,6 +291,14 @@ The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms fo
|
|||||||
| ssd mobilenet | ~ 25 ms |
|
| ssd mobilenet | ~ 25 ms |
|
||||||
| yolov5m | ~ 118 ms |
|
| yolov5m | ~ 118 ms |
|
||||||
|
|
||||||
|
### AXERA
|
||||||
|
|
||||||
|
- **AXEngine** Default model is **yolov9**
|
||||||
|
|
||||||
|
| Name | AXERA AX650N/AX8850N Inference Time |
|
||||||
|
| ---------------- | ----------------------------------- |
|
||||||
|
| yolov9-tiny | ~ 4 ms |
|
||||||
|
|
||||||
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
|
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
|
||||||
|
|
||||||
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
|
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
|
||||||
@ -307,4 +319,4 @@ Basically - When you increase the resolution and/or the frame rate of the stream
|
|||||||
|
|
||||||
YES! The Coral does not help with decoding video streams.
|
YES! The Coral does not help with decoding video streams.
|
||||||
|
|
||||||
Decompressing video streams takes a significant amount of CPU power. Video compression uses key frames (also known as I-frames) to send a full frame in the video stream. The following frames only include the difference from the key frame, and the CPU has to compile each frame by merging the differences with the key frame. [More detailed explanation](https://support.video.ibm.com/hc/en-us/articles/18106203580316-Keyframes-InterFrame-Video-Compression). Higher resolutions and frame rates mean more processing power is needed to decode the video stream, so try and set them on the camera to avoid unnecessary decoding work.
|
Decompressing video streams takes a significant amount of CPU power. Video compression uses key frames (also known as I-frames) to send a full frame in the video stream. The following frames only include the difference from the key frame, and the CPU has to compile each frame by merging the differences with the key frame. [More detailed explanation](https://support.video.ibm.com/hc/en-us/articles/18106203580316-Keyframes-InterFrame-Video-Compression). Higher resolutions and frame rates mean more processing power is needed to decode the video stream, so try and set them on the camera to avoid unnecessary decoding work.
|
||||||
@ -287,6 +287,42 @@ or add these options to your `docker run` command:
|
|||||||
|
|
||||||
Next, you should configure [hardware object detection](/configuration/object_detectors#synaptics) and [hardware video processing](/configuration/hardware_acceleration_video#synaptics).
|
Next, you should configure [hardware object detection](/configuration/object_detectors#synaptics) and [hardware video processing](/configuration/hardware_acceleration_video#synaptics).
|
||||||
|
|
||||||
|
### AXERA
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>AXERA accelerators</summary>
|
||||||
|
AXERA accelerators are available in an M.2 form factor, compatible with both Raspberry Pi and Orange Pi. This form factor has also been successfully tested on x86 platforms, making it a versatile choice for various computing environments.
|
||||||
|
|
||||||
|
#### Installation
|
||||||
|
|
||||||
|
Using AXERA accelerators requires the installation of the AXCL driver. We provide a convenient Linux script to complete this installation.
|
||||||
|
|
||||||
|
Follow these steps for installation:
|
||||||
|
|
||||||
|
1. Copy or download [this script](https://github.com/ivanshi1108/assets/releases/download/v0.16.2/user_installation.sh).
|
||||||
|
2. Ensure it has execution permissions with `sudo chmod +x user_installation.sh`
|
||||||
|
3. Run the script with `./user_installation.sh`
|
||||||
|
|
||||||
|
#### Setup
|
||||||
|
|
||||||
|
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
|
||||||
|
|
||||||
|
Next, grant Docker permissions to access your hardware by adding the following lines to your `docker-compose.yml` file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
devices:
|
||||||
|
- /dev/axcl_host
|
||||||
|
- /dev/ax_mmb_dev
|
||||||
|
- /dev/msg_userdev
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are using `docker run`, add this option to your command `--device /dev/axcl_host --device /dev/ax_mmb_dev --device /dev/msg_userdev`
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
|
||||||
|
Finally, configure [hardware object detection](/configuration/object_detectors#axera) to complete the setup.
|
||||||
|
</details>
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Running through Docker with Docker Compose is the recommended install method.
|
Running through Docker with Docker Compose is the recommended install method.
|
||||||
|
|||||||
@ -55,8 +55,8 @@ def require_admin_by_default():
|
|||||||
"/auth",
|
"/auth",
|
||||||
"/auth/first_time_login",
|
"/auth/first_time_login",
|
||||||
"/login",
|
"/login",
|
||||||
# Authenticated user endpoints (allow_any_authenticated)
|
|
||||||
"/logout",
|
"/logout",
|
||||||
|
# Authenticated user endpoints (allow_any_authenticated)
|
||||||
"/profile",
|
"/profile",
|
||||||
# Public info endpoints (allow_public)
|
# Public info endpoints (allow_public)
|
||||||
"/",
|
"/",
|
||||||
@ -311,7 +311,10 @@ def get_jwt_secret() -> str:
|
|||||||
)
|
)
|
||||||
jwt_secret = secrets.token_hex(64)
|
jwt_secret = secrets.token_hex(64)
|
||||||
try:
|
try:
|
||||||
with open(jwt_secret_file, "w") as f:
|
fd = os.open(
|
||||||
|
jwt_secret_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600
|
||||||
|
)
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
f.write(str(jwt_secret))
|
f.write(str(jwt_secret))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@ -356,9 +359,35 @@ def verify_password(password, password_hash):
|
|||||||
return secrets.compare_digest(password_hash, compare_hash)
|
return secrets.compare_digest(password_hash, compare_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password_strength(password: str) -> tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Validate password strength.
|
||||||
|
|
||||||
|
Returns a tuple of (is_valid, error_message).
|
||||||
|
"""
|
||||||
|
if not password:
|
||||||
|
return False, "Password cannot be empty"
|
||||||
|
|
||||||
|
if len(password) < 8:
|
||||||
|
return False, "Password must be at least 8 characters long"
|
||||||
|
|
||||||
|
if not any(c.isupper() for c in password):
|
||||||
|
return False, "Password must contain at least one uppercase letter"
|
||||||
|
|
||||||
|
if not any(c.isdigit() for c in password):
|
||||||
|
return False, "Password must contain at least one digit"
|
||||||
|
|
||||||
|
if not any(c in '!@#$%^&*(),.?":{}|<>' for c in password):
|
||||||
|
return False, "Password must contain at least one special character"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
def create_encoded_jwt(user, role, expiration, secret):
|
def create_encoded_jwt(user, role, expiration, secret):
|
||||||
return jwt.encode(
|
return jwt.encode(
|
||||||
{"alg": "HS256"}, {"sub": user, "role": role, "exp": expiration}, secret
|
{"alg": "HS256"},
|
||||||
|
{"sub": user, "role": role, "exp": expiration, "iat": int(time.time())},
|
||||||
|
secret,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -619,13 +648,27 @@ def auth(request: Request):
|
|||||||
return fail_response
|
return fail_response
|
||||||
|
|
||||||
# if the jwt cookie is expiring soon
|
# if the jwt cookie is expiring soon
|
||||||
elif jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time:
|
if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time:
|
||||||
logger.debug("jwt token expiring soon, refreshing cookie")
|
logger.debug("jwt token expiring soon, refreshing cookie")
|
||||||
# ensure the user hasn't been deleted
|
|
||||||
|
# Check if password has been changed since token was issued
|
||||||
|
# If so, force re-login by rejecting the refresh
|
||||||
try:
|
try:
|
||||||
User.get_by_id(user)
|
user_obj = User.get_by_id(user)
|
||||||
|
if user_obj.password_changed_at is not None:
|
||||||
|
token_iat = int(token.claims.get("iat", 0))
|
||||||
|
password_changed_timestamp = int(
|
||||||
|
user_obj.password_changed_at.timestamp()
|
||||||
|
)
|
||||||
|
if token_iat < password_changed_timestamp:
|
||||||
|
logger.debug(
|
||||||
|
"jwt token issued before password change, rejecting refresh"
|
||||||
|
)
|
||||||
|
return fail_response
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
|
logger.debug("user not found")
|
||||||
return fail_response
|
return fail_response
|
||||||
|
|
||||||
new_expiration = current_time + JWT_SESSION_LENGTH
|
new_expiration = current_time + JWT_SESSION_LENGTH
|
||||||
new_encoded_jwt = create_encoded_jwt(
|
new_encoded_jwt = create_encoded_jwt(
|
||||||
user, role, new_expiration, request.app.jwt_token
|
user, role, new_expiration, request.app.jwt_token
|
||||||
@ -660,7 +703,7 @@ def profile(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logout", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/logout", dependencies=[Depends(allow_public())])
|
||||||
def logout(request: Request):
|
def logout(request: Request):
|
||||||
auth_config: AuthConfig = request.app.frigate_config.auth
|
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||||
response = RedirectResponse("/login", status_code=303)
|
response = RedirectResponse("/login", status_code=303)
|
||||||
@ -782,10 +825,63 @@ async def update_password(
|
|||||||
|
|
||||||
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
||||||
|
|
||||||
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
try:
|
||||||
User.set_by_id(username, {User.password_hash: password_hash})
|
user = User.get_by_id(username)
|
||||||
|
except DoesNotExist:
|
||||||
|
return JSONResponse(content={"message": "User not found"}, status_code=404)
|
||||||
|
|
||||||
return JSONResponse(content={"success": True})
|
# Require old_password when:
|
||||||
|
# 1. Non-admin user is changing another user's password (admin only action)
|
||||||
|
# 2. Any user is changing their own password
|
||||||
|
is_changing_own_password = current_username == username
|
||||||
|
is_non_admin = current_role != "admin"
|
||||||
|
|
||||||
|
if is_changing_own_password or is_non_admin:
|
||||||
|
if not body.old_password:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"message": "Current password is required"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
if not verify_password(body.old_password, user.password_hash):
|
||||||
|
return JSONResponse(
|
||||||
|
content={"message": "Current password is incorrect"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate new password strength
|
||||||
|
is_valid, error_message = validate_password_strength(body.password)
|
||||||
|
if not is_valid:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"message": error_message},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
||||||
|
User.update(
|
||||||
|
{
|
||||||
|
User.password_hash: password_hash,
|
||||||
|
User.password_changed_at: datetime.now(),
|
||||||
|
}
|
||||||
|
).where(User.username == username).execute()
|
||||||
|
|
||||||
|
response = JSONResponse(content={"success": True})
|
||||||
|
|
||||||
|
# If user changed their own password, issue a new JWT to keep them logged in
|
||||||
|
if current_username == username:
|
||||||
|
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||||
|
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||||
|
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||||
|
|
||||||
|
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||||
|
encoded_jwt = create_encoded_jwt(
|
||||||
|
username, current_role, expiration, request.app.jwt_token
|
||||||
|
)
|
||||||
|
# Set new JWT cookie on response
|
||||||
|
set_jwt_cookie(
|
||||||
|
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
|
|||||||
@ -11,6 +11,7 @@ class AppConfigSetBody(BaseModel):
|
|||||||
|
|
||||||
class AppPutPasswordBody(BaseModel):
|
class AppPutPasswordBody(BaseModel):
|
||||||
password: str
|
password: str
|
||||||
|
old_password: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class AppPostUsersBody(BaseModel):
|
class AppPostUsersBody(BaseModel):
|
||||||
|
|||||||
@ -20,7 +20,7 @@ class AuthConfig(FrigateBaseModel):
|
|||||||
default=86400, title="Session length for jwt session tokens", ge=60
|
default=86400, title="Session length for jwt session tokens", ge=60
|
||||||
)
|
)
|
||||||
refresh_time: int = Field(
|
refresh_time: int = Field(
|
||||||
default=43200,
|
default=1800,
|
||||||
title="Refresh the session if it is going to expire in this many seconds",
|
title="Refresh the session if it is going to expire in this many seconds",
|
||||||
ge=30,
|
ge=30,
|
||||||
)
|
)
|
||||||
|
|||||||
86
frigate/detectors/plugins/axengine.py
Normal file
86
frigate/detectors/plugins/axengine.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import logging
|
||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import axengine as axe
|
||||||
|
|
||||||
|
from frigate.const import MODEL_CACHE_DIR
|
||||||
|
from frigate.detectors.detection_api import DetectionApi
|
||||||
|
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
|
||||||
|
from frigate.util.model import post_process_yolo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DETECTOR_KEY = "axengine"
|
||||||
|
|
||||||
|
supported_models = {
|
||||||
|
ModelTypeEnum.yologeneric: "frigate-yolov9-.*$",
|
||||||
|
}
|
||||||
|
|
||||||
|
model_cache_dir = os.path.join(MODEL_CACHE_DIR, "axengine_cache/")
|
||||||
|
|
||||||
|
|
||||||
|
class AxengineDetectorConfig(BaseDetectorConfig):
|
||||||
|
type: Literal[DETECTOR_KEY]
|
||||||
|
|
||||||
|
|
||||||
|
class Axengine(DetectionApi):
|
||||||
|
type_key = DETECTOR_KEY
|
||||||
|
|
||||||
|
def __init__(self, config: AxengineDetectorConfig):
|
||||||
|
logger.info("__init__ axengine")
|
||||||
|
super().__init__(config)
|
||||||
|
self.height = config.model.height
|
||||||
|
self.width = config.model.width
|
||||||
|
model_path = config.model.path or "frigate-yolov9-tiny"
|
||||||
|
model_props = self.parse_model_input(model_path)
|
||||||
|
self.session = axe.InferenceSession(model_props["path"])
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def parse_model_input(self, model_path):
|
||||||
|
model_props = {}
|
||||||
|
model_props["preset"] = True
|
||||||
|
|
||||||
|
model_matched = False
|
||||||
|
|
||||||
|
for model_type, pattern in supported_models.items():
|
||||||
|
if re.match(pattern, model_path):
|
||||||
|
model_matched = True
|
||||||
|
model_props["model_type"] = model_type
|
||||||
|
|
||||||
|
if model_matched:
|
||||||
|
model_props["filename"] = model_path + ".axmodel"
|
||||||
|
model_props["path"] = model_cache_dir + model_props["filename"]
|
||||||
|
|
||||||
|
if not os.path.isfile(model_props["path"]):
|
||||||
|
self.download_model(model_props["filename"])
|
||||||
|
else:
|
||||||
|
supported_models_str = ", ".join(model[1:-1] for model in supported_models)
|
||||||
|
raise Exception(
|
||||||
|
f"Model {model_path} is unsupported. Provide your own model or choose one of the following: {supported_models_str}"
|
||||||
|
)
|
||||||
|
return model_props
|
||||||
|
|
||||||
|
def download_model(self, filename):
|
||||||
|
if not os.path.isdir(model_cache_dir):
|
||||||
|
os.mkdir(model_cache_dir)
|
||||||
|
|
||||||
|
GITHUB_ENDPOINT = os.environ.get("GITHUB_ENDPOINT", "https://github.com")
|
||||||
|
urllib.request.urlretrieve(
|
||||||
|
f"{GITHUB_ENDPOINT}/ivanshi1108/assets/releases/download/v0.16.2/{filename}",
|
||||||
|
model_cache_dir + filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
def detect_raw(self, tensor_input):
|
||||||
|
results = None
|
||||||
|
results = self.session.run(None, {"images": tensor_input})
|
||||||
|
if self.detector_config.model.model_type == ModelTypeEnum.yologeneric:
|
||||||
|
return post_process_yolo(results, self.width, self.height)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f'Model type "{self.detector_config.model.model_type}" is currently not supported.'
|
||||||
|
)
|
||||||
@ -133,6 +133,7 @@ class User(Model):
|
|||||||
default="admin",
|
default="admin",
|
||||||
)
|
)
|
||||||
password_hash = CharField(null=False, max_length=120)
|
password_hash = CharField(null=False, max_length=120)
|
||||||
|
password_changed_at = DateTimeField(null=True)
|
||||||
notification_tokens = JSONField()
|
notification_tokens = JSONField()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@ -54,7 +54,9 @@ def migrate(migrator, database, fake=False, **kwargs):
|
|||||||
|
|
||||||
# Migrate existing has_been_reviewed data to UserReviewStatus for all users
|
# Migrate existing has_been_reviewed data to UserReviewStatus for all users
|
||||||
def migrate_data():
|
def migrate_data():
|
||||||
all_users = list(User.select())
|
# Use raw SQL to avoid ORM issues with columns that don't exist yet
|
||||||
|
cursor = database.execute_sql('SELECT "username" FROM "user"')
|
||||||
|
all_users = cursor.fetchall()
|
||||||
if not all_users:
|
if not all_users:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -63,7 +65,7 @@ def migrate(migrator, database, fake=False, **kwargs):
|
|||||||
)
|
)
|
||||||
reviewed_segment_ids = [row[0] for row in cursor.fetchall()]
|
reviewed_segment_ids = [row[0] for row in cursor.fetchall()]
|
||||||
# also migrate for anonymous (unauthenticated users)
|
# also migrate for anonymous (unauthenticated users)
|
||||||
usernames = [user.username for user in all_users] + ["anonymous"]
|
usernames = [user[0] for user in all_users] + ["anonymous"]
|
||||||
|
|
||||||
for segment_id in reviewed_segment_ids:
|
for segment_id in reviewed_segment_ids:
|
||||||
for username in usernames:
|
for username in usernames:
|
||||||
|
|||||||
42
migrations/032_add_password_changed_at.py
Normal file
42
migrations/032_add_password_changed_at.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""Peewee migrations -- 032_add_password_changed_at.py.
|
||||||
|
|
||||||
|
Some examples (model - class or model name)::
|
||||||
|
|
||||||
|
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||||
|
|
||||||
|
> migrator.sql(sql) # Run custom SQL
|
||||||
|
> migrator.python(func, *args, **kwargs) # Run python code
|
||||||
|
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||||
|
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||||
|
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||||
|
> migrator.change_fields(model, **fields) # Change fields
|
||||||
|
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||||
|
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||||
|
> migrator.rename_table(model, new_table_name)
|
||||||
|
> migrator.add_index(model, *col_names, unique=False)
|
||||||
|
> migrator.drop_index(model, *col_names)
|
||||||
|
> migrator.add_not_null(model, *field_names)
|
||||||
|
> migrator.drop_not_null(model, *field_names)
|
||||||
|
> migrator.add_default(model, field_name, default)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import peewee as pw
|
||||||
|
|
||||||
|
SQL = pw.SQL
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(migrator, database, fake=False, **kwargs):
|
||||||
|
migrator.sql(
|
||||||
|
"""
|
||||||
|
ALTER TABLE user ADD COLUMN password_changed_at DATETIME NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rollback(migrator, database, fake=False, **kwargs):
|
||||||
|
migrator.sql(
|
||||||
|
"""
|
||||||
|
ALTER TABLE user DROP COLUMN password_changed_at
|
||||||
|
"""
|
||||||
|
)
|
||||||
@ -712,6 +712,8 @@
|
|||||||
"password": {
|
"password": {
|
||||||
"title": "Password",
|
"title": "Password",
|
||||||
"placeholder": "Enter password",
|
"placeholder": "Enter password",
|
||||||
|
"show": "Show password",
|
||||||
|
"hide": "Hide password",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"title": "Confirm Password",
|
"title": "Confirm Password",
|
||||||
"placeholder": "Confirm Password"
|
"placeholder": "Confirm Password"
|
||||||
@ -723,6 +725,13 @@
|
|||||||
"strong": "Strong",
|
"strong": "Strong",
|
||||||
"veryStrong": "Very Strong"
|
"veryStrong": "Very Strong"
|
||||||
},
|
},
|
||||||
|
"requirements": {
|
||||||
|
"title": "Password requirements:",
|
||||||
|
"length": "At least 8 characters",
|
||||||
|
"uppercase": "At least one uppercase letter",
|
||||||
|
"digit": "At least one digit",
|
||||||
|
"special": "At least one special character (!@#$%^&*(),.?\":{}|<>)"
|
||||||
|
},
|
||||||
"match": "Passwords match",
|
"match": "Passwords match",
|
||||||
"notMatch": "Passwords don't match"
|
"notMatch": "Passwords don't match"
|
||||||
},
|
},
|
||||||
@ -733,6 +742,10 @@
|
|||||||
"placeholder": "Re-enter new password"
|
"placeholder": "Re-enter new password"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"currentPassword": {
|
||||||
|
"title": "Current Password",
|
||||||
|
"placeholder": "Enter your current password"
|
||||||
|
},
|
||||||
"usernameIsRequired": "Username is required",
|
"usernameIsRequired": "Username is required",
|
||||||
"passwordIsRequired": "Password is required"
|
"passwordIsRequired": "Password is required"
|
||||||
},
|
},
|
||||||
@ -750,9 +763,13 @@
|
|||||||
"passwordSetting": {
|
"passwordSetting": {
|
||||||
"cannotBeEmpty": "Password cannot be empty",
|
"cannotBeEmpty": "Password cannot be empty",
|
||||||
"doNotMatch": "Passwords do not match",
|
"doNotMatch": "Passwords do not match",
|
||||||
|
"currentPasswordRequired": "Current password is required",
|
||||||
|
"incorrectCurrentPassword": "Current password is incorrect",
|
||||||
|
"passwordVerificationFailed": "Failed to verify password",
|
||||||
"updatePassword": "Update Password for {{username}}",
|
"updatePassword": "Update Password for {{username}}",
|
||||||
"setPassword": "Set Password",
|
"setPassword": "Set Password",
|
||||||
"desc": "Create a strong password to secure this account."
|
"desc": "Create a strong password to secure this account.",
|
||||||
|
"multiDeviceWarning": "Any other devices where you are logged in will be required to re-login within {{refresh_time}}. You can also force all users to re-authenticate immediately by rotating your JWT secret."
|
||||||
},
|
},
|
||||||
"changeRole": {
|
"changeRole": {
|
||||||
"title": "Change User Role",
|
"title": "Change User Role",
|
||||||
|
|||||||
@ -42,19 +42,27 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
|||||||
const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`;
|
const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`;
|
||||||
|
|
||||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||||
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||||
|
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
||||||
|
|
||||||
const Container = isDesktop ? DropdownMenu : Drawer;
|
const Container = isDesktop ? DropdownMenu : Drawer;
|
||||||
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||||
const Content = isDesktop ? DropdownMenuContent : DrawerContent;
|
const Content = isDesktop ? DropdownMenuContent : DrawerContent;
|
||||||
const MenuItem = isDesktop ? DropdownMenuItem : DrawerClose;
|
const MenuItem = isDesktop ? DropdownMenuItem : DrawerClose;
|
||||||
|
|
||||||
const handlePasswordSave = async (password: string) => {
|
const handlePasswordSave = async (password: string, oldPassword?: string) => {
|
||||||
if (!profile?.username || profile.username === "anonymous") return;
|
if (!profile?.username || profile.username === "anonymous") return;
|
||||||
|
setIsPasswordLoading(true);
|
||||||
axios
|
axios
|
||||||
.put(`users/${profile.username}/password`, { password })
|
.put(`users/${profile.username}/password`, {
|
||||||
|
password,
|
||||||
|
old_password: oldPassword,
|
||||||
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
setPasswordDialogOpen(false);
|
setPasswordDialogOpen(false);
|
||||||
|
setPasswordError(null);
|
||||||
|
setIsPasswordLoading(false);
|
||||||
toast.success(t("users.toast.success.updatePassword"), {
|
toast.success(t("users.toast.success.updatePassword"), {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
@ -65,14 +73,10 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
|||||||
error.response?.data?.message ||
|
error.response?.data?.message ||
|
||||||
error.response?.data?.detail ||
|
error.response?.data?.detail ||
|
||||||
"Unknown error";
|
"Unknown error";
|
||||||
toast.error(
|
|
||||||
t("users.toast.error.setPasswordFailed", {
|
// Keep dialog open and show error
|
||||||
errorMessage,
|
setPasswordError(errorMessage);
|
||||||
}),
|
setIsPasswordLoading(false);
|
||||||
{
|
|
||||||
position: "top-center",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -154,8 +158,13 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
|||||||
<SetPasswordDialog
|
<SetPasswordDialog
|
||||||
show={passwordDialogOpen}
|
show={passwordDialogOpen}
|
||||||
onSave={handlePasswordSave}
|
onSave={handlePasswordSave}
|
||||||
onCancel={() => setPasswordDialogOpen(false)}
|
onCancel={() => {
|
||||||
|
setPasswordDialogOpen(false);
|
||||||
|
setPasswordError(null);
|
||||||
|
}}
|
||||||
|
initialError={passwordError}
|
||||||
username={profile?.username}
|
username={profile?.username}
|
||||||
|
isLoading={isPasswordLoading}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -116,13 +116,22 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent;
|
const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent;
|
||||||
const Portal = isDesktop ? DropdownMenuPortal : DialogPortal;
|
const Portal = isDesktop ? DropdownMenuPortal : DialogPortal;
|
||||||
|
|
||||||
const handlePasswordSave = async (password: string) => {
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||||
|
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
||||||
|
|
||||||
|
const handlePasswordSave = async (password: string, oldPassword?: string) => {
|
||||||
if (!profile?.username || profile.username === "anonymous") return;
|
if (!profile?.username || profile.username === "anonymous") return;
|
||||||
|
setIsPasswordLoading(true);
|
||||||
axios
|
axios
|
||||||
.put(`users/${profile.username}/password`, { password })
|
.put(`users/${profile.username}/password`, {
|
||||||
|
password,
|
||||||
|
old_password: oldPassword,
|
||||||
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
setPasswordDialogOpen(false);
|
setPasswordDialogOpen(false);
|
||||||
|
setPasswordError(null);
|
||||||
|
setIsPasswordLoading(false);
|
||||||
toast.success(
|
toast.success(
|
||||||
t("users.toast.success.updatePassword", {
|
t("users.toast.success.updatePassword", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
@ -138,15 +147,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
error.response?.data?.message ||
|
error.response?.data?.message ||
|
||||||
error.response?.data?.detail ||
|
error.response?.data?.detail ||
|
||||||
"Unknown error";
|
"Unknown error";
|
||||||
toast.error(
|
|
||||||
t("users.toast.error.setPasswordFailed", {
|
// Keep dialog open and show error
|
||||||
ns: "views/settings",
|
setPasswordError(errorMessage);
|
||||||
errorMessage,
|
setIsPasswordLoading(false);
|
||||||
}),
|
|
||||||
{
|
|
||||||
position: "top-center",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -554,8 +558,13 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
<SetPasswordDialog
|
<SetPasswordDialog
|
||||||
show={passwordDialogOpen}
|
show={passwordDialogOpen}
|
||||||
onSave={handlePasswordSave}
|
onSave={handlePasswordSave}
|
||||||
onCancel={() => setPasswordDialogOpen(false)}
|
onCancel={() => {
|
||||||
|
setPasswordDialogOpen(false);
|
||||||
|
setPasswordError(null);
|
||||||
|
}}
|
||||||
|
initialError={passwordError}
|
||||||
username={profile?.username}
|
username={profile?.username}
|
||||||
|
isLoading={isPasswordLoading}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -11,71 +9,187 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../ui/dialog";
|
} from "../ui/dialog";
|
||||||
|
import {
|
||||||
import { Label } from "../ui/label";
|
Form,
|
||||||
import { LuCheck, LuX } from "react-icons/lu";
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../ui/form";
|
||||||
|
import { LuCheck, LuX, LuEye, LuEyeOff, LuExternalLink } from "react-icons/lu";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { formatSecondsToDuration } from "@/utils/dateUtil";
|
||||||
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
type SetPasswordProps = {
|
type SetPasswordProps = {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
onSave: (password: string) => void;
|
onSave: (password: string, oldPassword?: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
initialError?: string | null;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SetPasswordDialog({
|
export default function SetPasswordDialog({
|
||||||
show,
|
show,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
initialError,
|
||||||
username,
|
username,
|
||||||
|
isLoading = false,
|
||||||
}: SetPasswordProps) {
|
}: SetPasswordProps) {
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings", "common"]);
|
||||||
const [password, setPassword] = useState<string>("");
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
const [confirmPassword, setConfirmPassword] = useState<string>("");
|
|
||||||
const [passwordStrength, setPasswordStrength] = useState<number>(0);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Reset state when dialog opens/closes
|
const { data: config } = useSWR("config");
|
||||||
useEffect(() => {
|
const refreshSeconds: number | undefined =
|
||||||
if (show) {
|
config?.auth?.refresh_time ?? undefined;
|
||||||
setPassword("");
|
const refreshTimeLabel = refreshSeconds
|
||||||
setConfirmPassword("");
|
? formatSecondsToDuration(refreshSeconds)
|
||||||
setError(null);
|
: "30 minutes";
|
||||||
}
|
|
||||||
}, [show]);
|
|
||||||
|
|
||||||
// Simple password strength calculation
|
// visibility toggles for password fields
|
||||||
useEffect(() => {
|
const [showOldPassword, setShowOldPassword] = useState<boolean>(false);
|
||||||
if (!password) {
|
const [showPasswordVisible, setShowPasswordVisible] =
|
||||||
setPasswordStrength(0);
|
useState<boolean>(false);
|
||||||
return;
|
const [showConfirmPassword, setShowConfirmPassword] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
|
// Create form schema with conditional old password requirement
|
||||||
|
const formSchema = useMemo(() => {
|
||||||
|
const baseSchema = {
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, t("users.dialog.form.password.requirements.length"))
|
||||||
|
.regex(/[A-Z]/, t("users.dialog.form.password.requirements.uppercase"))
|
||||||
|
.regex(/\d/, t("users.dialog.form.password.requirements.digit"))
|
||||||
|
.regex(
|
||||||
|
/[!@#$%^&*(),.?":{}|<>]/,
|
||||||
|
t("users.dialog.form.password.requirements.special"),
|
||||||
|
),
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (username) {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
oldPassword: z
|
||||||
|
.string()
|
||||||
|
.min(1, t("users.dialog.passwordSetting.currentPasswordRequired")),
|
||||||
|
...baseSchema,
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: t("users.dialog.passwordSetting.doNotMatch"),
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return z
|
||||||
|
.object(baseSchema)
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: t("users.dialog.passwordSetting.doNotMatch"),
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}, [username, t]);
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const defaultValues = username
|
||||||
|
? {
|
||||||
|
oldPassword: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: defaultValues as FormValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const password = form.watch("password");
|
||||||
|
const confirmPassword = form.watch("confirmPassword");
|
||||||
|
|
||||||
|
// Password strength calculation
|
||||||
|
const passwordStrength = useMemo(() => {
|
||||||
|
if (!password) return 0;
|
||||||
|
|
||||||
let strength = 0;
|
let strength = 0;
|
||||||
// Length check
|
|
||||||
if (password.length >= 8) strength += 1;
|
if (password.length >= 8) strength += 1;
|
||||||
// Contains number
|
|
||||||
if (/\d/.test(password)) strength += 1;
|
if (/\d/.test(password)) strength += 1;
|
||||||
// Contains special char
|
|
||||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1;
|
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1;
|
||||||
// Contains uppercase
|
|
||||||
if (/[A-Z]/.test(password)) strength += 1;
|
if (/[A-Z]/.test(password)) strength += 1;
|
||||||
|
|
||||||
setPasswordStrength(strength);
|
return strength;
|
||||||
}, [password]);
|
}, [password]);
|
||||||
|
|
||||||
const handleSave = () => {
|
const requirements = useMemo(
|
||||||
if (!password) {
|
() => ({
|
||||||
setError(t("users.dialog.passwordSetting.cannotBeEmpty"));
|
length: password?.length >= 8,
|
||||||
return;
|
uppercase: /[A-Z]/.test(password || ""),
|
||||||
}
|
digit: /\d/.test(password || ""),
|
||||||
|
special: /[!@#$%^&*(),.?":{}|<>]/.test(password || ""),
|
||||||
|
}),
|
||||||
|
[password],
|
||||||
|
);
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
// Reset form and visibility toggles when dialog opens/closes
|
||||||
setError(t("users.dialog.passwordSetting.doNotMatch"));
|
useEffect(() => {
|
||||||
return;
|
if (show) {
|
||||||
|
form.reset();
|
||||||
|
setShowOldPassword(false);
|
||||||
|
setShowPasswordVisible(false);
|
||||||
|
setShowConfirmPassword(false);
|
||||||
}
|
}
|
||||||
|
}, [show, form]);
|
||||||
|
|
||||||
onSave(password);
|
// Handle backend errors
|
||||||
|
useEffect(() => {
|
||||||
|
if (show && initialError) {
|
||||||
|
const errorMsg = String(initialError);
|
||||||
|
// Check if the error is about incorrect current password
|
||||||
|
if (
|
||||||
|
errorMsg.toLowerCase().includes("current password is incorrect") ||
|
||||||
|
errorMsg.toLowerCase().includes("current password incorrect")
|
||||||
|
) {
|
||||||
|
if (username) {
|
||||||
|
form.setError("oldPassword" as keyof FormValues, {
|
||||||
|
type: "manual",
|
||||||
|
message: t("users.dialog.passwordSetting.incorrectCurrentPassword"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For other errors, show as form-level error
|
||||||
|
form.setError("root", {
|
||||||
|
type: "manual",
|
||||||
|
message: errorMsg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [show, initialError, form, t, username]);
|
||||||
|
|
||||||
|
const onSubmit = async (values: FormValues) => {
|
||||||
|
const oldPassword =
|
||||||
|
"oldPassword" in values
|
||||||
|
? (
|
||||||
|
values as {
|
||||||
|
oldPassword: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
).oldPassword
|
||||||
|
: undefined;
|
||||||
|
onSave(values.password, oldPassword);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStrengthLabel = () => {
|
const getStrengthLabel = () => {
|
||||||
@ -112,113 +226,333 @@ export default function SetPasswordDialog({
|
|||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("users.dialog.passwordSetting.desc")}
|
{t("users.dialog.passwordSetting.desc")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("users.dialog.passwordSetting.multiDeviceWarning", {
|
||||||
|
refresh_time: refreshTimeLabel,
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-primary-variant">
|
||||||
|
<a
|
||||||
|
href={getLocaleDocUrl(
|
||||||
|
"configuration/authentication#jwt-token-secret",
|
||||||
|
)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center text-primary"
|
||||||
|
>
|
||||||
|
{t("readTheDocumentation", { ns: "common" })}
|
||||||
|
<LuExternalLink className="ml-2 size-3" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 pt-4">
|
<Form {...form}>
|
||||||
<div className="space-y-2">
|
<form
|
||||||
<Label htmlFor="password">
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
{t("users.dialog.form.newPassword.title")}
|
className="space-y-4 pt-4"
|
||||||
</Label>
|
>
|
||||||
<Input
|
{username && (
|
||||||
id="password"
|
<FormField
|
||||||
className="h-10"
|
control={form.control}
|
||||||
type="password"
|
name={"oldPassword" as keyof FormValues}
|
||||||
value={password}
|
render={({ field }) => (
|
||||||
onChange={(event) => {
|
<FormItem>
|
||||||
setPassword(event.target.value);
|
<FormLabel>
|
||||||
setError(null);
|
{t("users.dialog.form.currentPassword.title")}
|
||||||
}}
|
</FormLabel>
|
||||||
placeholder={t("users.dialog.form.newPassword.placeholder")}
|
<FormControl>
|
||||||
autoFocus
|
<div className="relative">
|
||||||
/>
|
<Input
|
||||||
|
{...field}
|
||||||
{/* Password strength indicator */}
|
type={showOldPassword ? "text" : "password"}
|
||||||
{password && (
|
placeholder={t(
|
||||||
<div className="mt-2 space-y-1">
|
"users.dialog.form.currentPassword.placeholder",
|
||||||
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
|
)}
|
||||||
<div
|
className="h-10 pr-10"
|
||||||
className={`${getStrengthColor()} transition-all duration-300`}
|
/>
|
||||||
style={{ width: `${(passwordStrength / 3) * 100}%` }}
|
<Button
|
||||||
/>
|
type="button"
|
||||||
</div>
|
variant="ghost"
|
||||||
<p className="text-xs text-muted-foreground">
|
size="sm"
|
||||||
{t("users.dialog.form.password.strength.title")}
|
tabIndex={-1}
|
||||||
<span className="font-medium">{getStrengthLabel()}</span>
|
aria-label={
|
||||||
</p>
|
showOldPassword
|
||||||
</div>
|
? t("users.dialog.form.password.hide", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})
|
||||||
|
: t("users.dialog.form.password.show", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||||
|
onClick={() => setShowOldPassword(!showOldPassword)}
|
||||||
|
>
|
||||||
|
{showOldPassword ? (
|
||||||
|
<LuEyeOff className="size-4" />
|
||||||
|
) : (
|
||||||
|
<LuEye className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="confirm-password">
|
control={form.control}
|
||||||
{t("users.dialog.form.password.confirm.title")}
|
name="password"
|
||||||
</Label>
|
render={({ field }) => (
|
||||||
<Input
|
<FormItem>
|
||||||
id="confirm-password"
|
<FormLabel>
|
||||||
className="h-10"
|
{t("users.dialog.form.newPassword.title")}
|
||||||
type="password"
|
</FormLabel>
|
||||||
value={confirmPassword}
|
<FormControl>
|
||||||
onChange={(event) => {
|
<div className="relative">
|
||||||
setConfirmPassword(event.target.value);
|
<Input
|
||||||
setError(null);
|
{...field}
|
||||||
}}
|
type={showPasswordVisible ? "text" : "password"}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"users.dialog.form.newPassword.confirm.placeholder",
|
"users.dialog.form.newPassword.placeholder",
|
||||||
|
)}
|
||||||
|
className="h-10 pr-10"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={
|
||||||
|
showPasswordVisible
|
||||||
|
? t("users.dialog.form.password.hide", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})
|
||||||
|
: t("users.dialog.form.password.show", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||||
|
onClick={() =>
|
||||||
|
setShowPasswordVisible(!showPasswordVisible)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showPasswordVisible ? (
|
||||||
|
<LuEyeOff className="size-4" />
|
||||||
|
) : (
|
||||||
|
<LuEye className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{password && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
|
||||||
|
<div
|
||||||
|
className={`${getStrengthColor()} transition-all duration-300`}
|
||||||
|
style={{ width: `${(passwordStrength / 4) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("users.dialog.form.password.strength.title")}
|
||||||
|
<span className="font-medium">
|
||||||
|
{getStrengthLabel()}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-1 rounded-md bg-muted/50 p-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("users.dialog.form.password.requirements.title")}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
<li className="flex items-center gap-2 text-xs">
|
||||||
|
{requirements.length ? (
|
||||||
|
<LuCheck className="size-3.5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<LuX className="size-3.5 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
requirements.length
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"users.dialog.form.password.requirements.length",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2 text-xs">
|
||||||
|
{requirements.uppercase ? (
|
||||||
|
<LuCheck className="size-3.5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<LuX className="size-3.5 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
requirements.uppercase
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"users.dialog.form.password.requirements.uppercase",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2 text-xs">
|
||||||
|
{requirements.digit ? (
|
||||||
|
<LuCheck className="size-3.5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<LuX className="size-3.5 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
requirements.digit
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"users.dialog.form.password.requirements.digit",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2 text-xs">
|
||||||
|
{requirements.special ? (
|
||||||
|
<LuCheck className="size-3.5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<LuX className="size-3.5 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
requirements.special
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"users.dialog.form.password.requirements.special",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Password match indicator */}
|
<FormField
|
||||||
{password && confirmPassword && (
|
control={form.control}
|
||||||
<div className="mt-1 flex items-center gap-1.5 text-xs">
|
name="confirmPassword"
|
||||||
{password === confirmPassword ? (
|
render={({ field }) => (
|
||||||
<>
|
<FormItem>
|
||||||
<LuCheck className="size-3.5 text-green-500" />
|
<FormLabel>
|
||||||
<span className="text-green-600">
|
{t("users.dialog.form.password.confirm.title")}
|
||||||
{t("users.dialog.form.password.match")}
|
</FormLabel>
|
||||||
</span>
|
<FormControl>
|
||||||
</>
|
<div className="relative">
|
||||||
) : (
|
<Input
|
||||||
<>
|
{...field}
|
||||||
<LuX className="size-3.5 text-red-500" />
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
<span className="text-red-600">
|
placeholder={t(
|
||||||
{t("users.dialog.form.password.notMatch")}
|
"users.dialog.form.newPassword.confirm.placeholder",
|
||||||
</span>
|
)}
|
||||||
</>
|
className="h-10 pr-10"
|
||||||
)}
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={
|
||||||
|
showConfirmPassword
|
||||||
|
? t("users.dialog.form.password.hide", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})
|
||||||
|
: t("users.dialog.form.password.show", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||||
|
onClick={() =>
|
||||||
|
setShowConfirmPassword(!showConfirmPassword)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? (
|
||||||
|
<LuEyeOff className="size-4" />
|
||||||
|
) : (
|
||||||
|
<LuEye className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{password &&
|
||||||
|
confirmPassword &&
|
||||||
|
password === confirmPassword && (
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-xs">
|
||||||
|
<LuCheck className="size-3.5 text-green-500" />
|
||||||
|
<span className="text-green-600">
|
||||||
|
{t("users.dialog.form.password.match")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.formState.errors.root && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{form.formState.errors.root.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
<div className="flex flex-1 flex-col justify-end">
|
||||||
{error}
|
<div className="flex flex-row gap-2 pt-5">
|
||||||
</div>
|
<Button
|
||||||
)}
|
className="flex flex-1"
|
||||||
</div>
|
aria-label={t("button.cancel", { ns: "common" })}
|
||||||
|
onClick={onCancel}
|
||||||
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
type="button"
|
||||||
<div className="flex flex-1 flex-col justify-end">
|
disabled={isLoading}
|
||||||
<div className="flex flex-row gap-2 pt-5">
|
>
|
||||||
<Button
|
{t("button.cancel", { ns: "common" })}
|
||||||
className="flex flex-1"
|
</Button>
|
||||||
aria-label={t("button.cancel", { ns: "common" })}
|
<Button
|
||||||
onClick={onCancel}
|
variant="select"
|
||||||
type="button"
|
aria-label={t("button.save", { ns: "common" })}
|
||||||
>
|
className="flex flex-1"
|
||||||
{t("button.cancel", { ns: "common" })}
|
type="submit"
|
||||||
</Button>
|
disabled={isLoading || !form.formState.isValid}
|
||||||
<Button
|
>
|
||||||
variant="select"
|
{isLoading ? (
|
||||||
aria-label={t("button.save", { ns: "common" })}
|
<div className="flex flex-row items-center gap-2">
|
||||||
className="flex flex-1"
|
<ActivityIndicator />
|
||||||
onClick={handleSave}
|
<span>{t("button.saving", { ns: "common" })}</span>
|
||||||
disabled={!password || password !== confirmPassword}
|
</div>
|
||||||
>
|
) : (
|
||||||
{t("button.save", { ns: "common" })}
|
t("button.save", { ns: "common" })
|
||||||
</Button>
|
)}
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -57,6 +57,8 @@ export default function AuthenticationView({
|
|||||||
const [showCreateRole, setShowCreateRole] = useState(false);
|
const [showCreateRole, setShowCreateRole] = useState(false);
|
||||||
const [showEditRole, setShowEditRole] = useState(false);
|
const [showEditRole, setShowEditRole] = useState(false);
|
||||||
const [showDeleteRole, setShowDeleteRole] = useState(false);
|
const [showDeleteRole, setShowDeleteRole] = useState(false);
|
||||||
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||||
|
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
||||||
|
|
||||||
const [selectedUser, setSelectedUser] = useState<string>();
|
const [selectedUser, setSelectedUser] = useState<string>();
|
||||||
const [selectedUserRole, setSelectedUserRole] = useState<string>();
|
const [selectedUserRole, setSelectedUserRole] = useState<string>();
|
||||||
@ -70,12 +72,15 @@ export default function AuthenticationView({
|
|||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
const onSavePassword = useCallback(
|
const onSavePassword = useCallback(
|
||||||
(user: string, password: string) => {
|
(user: string, password: string, oldPassword?: string) => {
|
||||||
|
setIsPasswordLoading(true);
|
||||||
axios
|
axios
|
||||||
.put(`users/${user}/password`, { password })
|
.put(`users/${user}/password`, { password, old_password: oldPassword })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
setShowSetPassword(false);
|
setShowSetPassword(false);
|
||||||
|
setPasswordError(null);
|
||||||
|
setIsPasswordLoading(false);
|
||||||
toast.success(t("users.toast.success.updatePassword"), {
|
toast.success(t("users.toast.success.updatePassword"), {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
@ -86,14 +91,10 @@ export default function AuthenticationView({
|
|||||||
error.response?.data?.message ||
|
error.response?.data?.message ||
|
||||||
error.response?.data?.detail ||
|
error.response?.data?.detail ||
|
||||||
"Unknown error";
|
"Unknown error";
|
||||||
toast.error(
|
|
||||||
t("users.toast.error.setPasswordFailed", {
|
// Keep dialog open and show error
|
||||||
errorMessage,
|
setPasswordError(errorMessage);
|
||||||
}),
|
setIsPasswordLoading(false);
|
||||||
{
|
|
||||||
position: "top-center",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[t],
|
[t],
|
||||||
@ -563,8 +564,15 @@ export default function AuthenticationView({
|
|||||||
</div>
|
</div>
|
||||||
<SetPasswordDialog
|
<SetPasswordDialog
|
||||||
show={showSetPassword}
|
show={showSetPassword}
|
||||||
onCancel={() => setShowSetPassword(false)}
|
onCancel={() => {
|
||||||
onSave={(password) => onSavePassword(selectedUser!, password)}
|
setShowSetPassword(false);
|
||||||
|
setPasswordError(null);
|
||||||
|
}}
|
||||||
|
initialError={passwordError}
|
||||||
|
onSave={(password, oldPassword) =>
|
||||||
|
onSavePassword(selectedUser!, password, oldPassword)
|
||||||
|
}
|
||||||
|
isLoading={isPasswordLoading}
|
||||||
/>
|
/>
|
||||||
<DeleteUserDialog
|
<DeleteUserDialog
|
||||||
show={showDelete}
|
show={showDelete}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user