Compare commits

...

15 Commits

Author SHA1 Message Date
ivanshi1108
265a0f304f
Merge acb17a7b50 into dfd837cfb0 2025-12-09 01:23:59 +09:00
Josh Hawkins
dfd837cfb0
refactor to use react-hook-form and zod (#21195)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2025-12-08 09:19:34 -07:00
Josh Hawkins
152e585206
Authentication improvements (#21194)
* jwt permissions

* add old password to body req

* add model and migration

need to track the datetime that passwords were changed for the jwt

* auth api backend changes

- use os.open to create jwt secret with restrictive permissions (0o600: read/write for owner only)
- add backend validation for password strength
- add iat claim to jwt so the server can determine when a token was issued and reject any jwts issued before a user's password_changed_at timestamp, ensuring old tokens are invalidated after a password change
- set logout route to public to avoid 401 when logging out
- issue new jwt for users who change their own password so they stay logged in

* improve set password dialog

- add field to verify old password
- add password strength requirements

* frontend tweaks for password dialog

* i18n

* use verify endpoint for existing password verification

avoid /login side effects (creating a new session)

* public logout

* only check if password has changed on jwt refresh

* fix tests

Fix migration 030 by using raw sql to select usernames (avoid ORM selecting nonexistent columns)

* add multi device warning to password dialog

* remove password verification endpoint

Just send old_password + new password in one request, let the backend handle verification in a single operation
2025-12-08 09:02:28 -07:00
shizhicheng
acb17a7b50 Format code based on the results of Python Checks
x
2025-12-01 04:47:39 +00:00
ivanshi1108
7933a83a42
Update docs/docs/configuration/object_detectors.md
Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2025-11-24 23:04:19 +08:00
shizhicheng
2eef58aa1d Modify the description of AXERA in the documentation. 2025-11-24 07:04:42 +00:00
ivanshi1108
6659b7cb0f
Merge branch 'dev' into AXERA-axcl 2025-11-24 10:55:09 +08:00
shizhicheng
f134796913 format code with ruff 2025-11-24 02:42:04 +00:00
shizhicheng
b4abbd7d3b Modify the document based on review suggestions 2025-11-24 02:20:40 +00:00
shizhicheng
438df7d484 The model inference time has been changed to the time displayed on the Frigate UI 2025-11-16 22:22:38 +08:00
shizhicheng
e27a94ae0b Fix logical errors caused by code formatting 2025-11-11 05:54:19 +00:00
shizhicheng
1dee548dbc Modifications to the YOLOv9 object detection model:
The model is now dynamically downloaded to the cache directory.
Post-processing is now done using Frigate's built-in `post_process_yolo`.
Configuration in the relevant documentation has been updated.
2025-11-11 05:42:28 +00:00
shizhicheng
91e17e12b7 Change the default detection model to YOLOv9 2025-11-09 13:21:17 +00:00
ivanshi1108
bb45483e9e
Modify AXERA section from hardware.md
Modify AXERA section and related content from hardware documentation.
2025-10-28 09:54:00 +08:00
shizhicheng
7b4eaf2d10 Initial commit for AXERA AI accelerators 2025-10-24 09:03:13 +00:00
21 changed files with 1072 additions and 187 deletions

View File

@ -224,3 +224,29 @@ jobs:
sources: |
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-amd64
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
View 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
View 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
View 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

View 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

View File

@ -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.
**AXERA** <CommunityBadge />
- [AXEngine](#axera): axmodels can run on AXERA AI acceleration.
**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.
@ -1476,6 +1481,41 @@ model:
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
Some model types are not included in Frigate by default.

View File

@ -123,7 +123,7 @@ auth:
# Optional: Refresh time in seconds (default: shown below)
# When the session is going to expire in less time than this setting,
# 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
# login attacks (default: shown below)
# See the docs for more information on valid values

View File

@ -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.
**AXERA** <CommunityBadge />
- [AXEngine](#axera): axera models can run on AXERA NPUs via AXEngine, delivering highly efficient object detection.
:::
### 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 |
| 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)
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.

View File

@ -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).
### 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
Running through Docker with Docker Compose is the recommended install method.

View File

@ -55,8 +55,8 @@ def require_admin_by_default():
"/auth",
"/auth/first_time_login",
"/login",
# Authenticated user endpoints (allow_any_authenticated)
"/logout",
# Authenticated user endpoints (allow_any_authenticated)
"/profile",
# Public info endpoints (allow_public)
"/",
@ -311,7 +311,10 @@ def get_jwt_secret() -> str:
)
jwt_secret = secrets.token_hex(64)
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))
except Exception:
logger.warning(
@ -356,9 +359,35 @@ def verify_password(password, password_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):
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
# 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")
# 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:
User.get_by_id(user)
except DoesNotExist:
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:
logger.debug("user not found")
return fail_response
new_expiration = current_time + JWT_SESSION_LENGTH
new_encoded_jwt = create_encoded_jwt(
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):
auth_config: AuthConfig = request.app.frigate_config.auth
response = RedirectResponse("/login", status_code=303)
@ -782,10 +825,63 @@ async def update_password(
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
User.set_by_id(username, {User.password_hash: password_hash})
try:
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(

View File

@ -11,6 +11,7 @@ class AppConfigSetBody(BaseModel):
class AppPutPasswordBody(BaseModel):
password: str
old_password: Optional[str] = None
class AppPostUsersBody(BaseModel):

View File

@ -20,7 +20,7 @@ class AuthConfig(FrigateBaseModel):
default=86400, title="Session length for jwt session tokens", ge=60
)
refresh_time: int = Field(
default=43200,
default=1800,
title="Refresh the session if it is going to expire in this many seconds",
ge=30,
)

View 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.'
)

View File

@ -133,6 +133,7 @@ class User(Model):
default="admin",
)
password_hash = CharField(null=False, max_length=120)
password_changed_at = DateTimeField(null=True)
notification_tokens = JSONField()
@classmethod

View File

@ -54,7 +54,9 @@ def migrate(migrator, database, fake=False, **kwargs):
# Migrate existing has_been_reviewed data to UserReviewStatus for all users
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:
return
@ -63,7 +65,7 @@ def migrate(migrator, database, fake=False, **kwargs):
)
reviewed_segment_ids = [row[0] for row in cursor.fetchall()]
# 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 username in usernames:

View 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
"""
)

View File

@ -712,6 +712,8 @@
"password": {
"title": "Password",
"placeholder": "Enter password",
"show": "Show password",
"hide": "Hide password",
"confirm": {
"title": "Confirm Password",
"placeholder": "Confirm Password"
@ -723,6 +725,13 @@
"strong": "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",
"notMatch": "Passwords don't match"
},
@ -733,6 +742,10 @@
"placeholder": "Re-enter new password"
}
},
"currentPassword": {
"title": "Current Password",
"placeholder": "Enter your current password"
},
"usernameIsRequired": "Username is required",
"passwordIsRequired": "Password is required"
},
@ -750,9 +763,13 @@
"passwordSetting": {
"cannotBeEmpty": "Password cannot be empty",
"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}}",
"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": {
"title": "Change User Role",

View File

@ -42,19 +42,27 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`;
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const Container = isDesktop ? DropdownMenu : Drawer;
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
const Content = isDesktop ? DropdownMenuContent : DrawerContent;
const MenuItem = isDesktop ? DropdownMenuItem : DrawerClose;
const handlePasswordSave = async (password: string) => {
const handlePasswordSave = async (password: string, oldPassword?: string) => {
if (!profile?.username || profile.username === "anonymous") return;
setIsPasswordLoading(true);
axios
.put(`users/${profile.username}/password`, { password })
.put(`users/${profile.username}/password`, {
password,
old_password: oldPassword,
})
.then((response) => {
if (response.status === 200) {
setPasswordDialogOpen(false);
setPasswordError(null);
setIsPasswordLoading(false);
toast.success(t("users.toast.success.updatePassword"), {
position: "top-center",
});
@ -65,14 +73,10 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("users.toast.error.setPasswordFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
// Keep dialog open and show error
setPasswordError(errorMessage);
setIsPasswordLoading(false);
});
};
@ -154,8 +158,13 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
<SetPasswordDialog
show={passwordDialogOpen}
onSave={handlePasswordSave}
onCancel={() => setPasswordDialogOpen(false)}
onCancel={() => {
setPasswordDialogOpen(false);
setPasswordError(null);
}}
initialError={passwordError}
username={profile?.username}
isLoading={isPasswordLoading}
/>
</Container>
);

View File

@ -116,13 +116,22 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent;
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;
setIsPasswordLoading(true);
axios
.put(`users/${profile.username}/password`, { password })
.put(`users/${profile.username}/password`, {
password,
old_password: oldPassword,
})
.then((response) => {
if (response.status === 200) {
setPasswordDialogOpen(false);
setPasswordError(null);
setIsPasswordLoading(false);
toast.success(
t("users.toast.success.updatePassword", {
ns: "views/settings",
@ -138,15 +147,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("users.toast.error.setPasswordFailed", {
ns: "views/settings",
errorMessage,
}),
{
position: "top-center",
},
);
// Keep dialog open and show error
setPasswordError(errorMessage);
setIsPasswordLoading(false);
});
};
@ -554,8 +558,13 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<SetPasswordDialog
show={passwordDialogOpen}
onSave={handlePasswordSave}
onCancel={() => setPasswordDialogOpen(false)}
onCancel={() => {
setPasswordDialogOpen(false);
setPasswordError(null);
}}
initialError={passwordError}
username={profile?.username}
isLoading={isPasswordLoading}
/>
</>
);

View File

@ -1,8 +1,6 @@
"use client";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import {
Dialog,
DialogContent,
@ -11,71 +9,187 @@ import {
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Label } from "../ui/label";
import { LuCheck, LuX } from "react-icons/lu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../ui/form";
import { LuCheck, LuX, LuEye, LuEyeOff, LuExternalLink } from "react-icons/lu";
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 = {
show: boolean;
onSave: (password: string) => void;
onSave: (password: string, oldPassword?: string) => void;
onCancel: () => void;
initialError?: string | null;
username?: string;
isLoading?: boolean;
};
export default function SetPasswordDialog({
show,
onSave,
onCancel,
initialError,
username,
isLoading = false,
}: SetPasswordProps) {
const { t } = useTranslation(["views/settings"]);
const [password, setPassword] = useState<string>("");
const [confirmPassword, setConfirmPassword] = useState<string>("");
const [passwordStrength, setPasswordStrength] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
const { t } = useTranslation(["views/settings", "common"]);
const { getLocaleDocUrl } = useDocDomain();
// Reset state when dialog opens/closes
useEffect(() => {
if (show) {
setPassword("");
setConfirmPassword("");
setError(null);
}
}, [show]);
const { data: config } = useSWR("config");
const refreshSeconds: number | undefined =
config?.auth?.refresh_time ?? undefined;
const refreshTimeLabel = refreshSeconds
? formatSecondsToDuration(refreshSeconds)
: "30 minutes";
// Simple password strength calculation
useEffect(() => {
if (!password) {
setPasswordStrength(0);
return;
// visibility toggles for password fields
const [showOldPassword, setShowOldPassword] = useState<boolean>(false);
const [showPasswordVisible, setShowPasswordVisible] =
useState<boolean>(false);
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;
// Length check
if (password.length >= 8) strength += 1;
// Contains number
if (/\d/.test(password)) strength += 1;
// Contains special char
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1;
// Contains uppercase
if (/[A-Z]/.test(password)) strength += 1;
setPasswordStrength(strength);
return strength;
}, [password]);
const handleSave = () => {
if (!password) {
setError(t("users.dialog.passwordSetting.cannotBeEmpty"));
return;
}
const requirements = useMemo(
() => ({
length: password?.length >= 8,
uppercase: /[A-Z]/.test(password || ""),
digit: /\d/.test(password || ""),
special: /[!@#$%^&*(),.?":{}|<>]/.test(password || ""),
}),
[password],
);
if (password !== confirmPassword) {
setError(t("users.dialog.passwordSetting.doNotMatch"));
return;
// Reset form and visibility toggles when dialog opens/closes
useEffect(() => {
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 = () => {
@ -112,89 +226,299 @@ export default function SetPasswordDialog({
<DialogDescription>
{t("users.dialog.passwordSetting.desc")}
</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>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label htmlFor="password">
{t("users.dialog.form.newPassword.title")}
</Label>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4 pt-4"
>
{username && (
<FormField
control={form.control}
name={"oldPassword" as keyof FormValues}
render={({ field }) => (
<FormItem>
<FormLabel>
{t("users.dialog.form.currentPassword.title")}
</FormLabel>
<FormControl>
<div className="relative">
<Input
id="password"
className="h-10"
type="password"
value={password}
onChange={(event) => {
setPassword(event.target.value);
setError(null);
}}
placeholder={t("users.dialog.form.newPassword.placeholder")}
{...field}
type={showOldPassword ? "text" : "password"}
placeholder={t(
"users.dialog.form.currentPassword.placeholder",
)}
className="h-10 pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
tabIndex={-1}
aria-label={
showOldPassword
? 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>
)}
/>
)}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("users.dialog.form.newPassword.title")}
</FormLabel>
<FormControl>
<div className="relative">
<Input
{...field}
type={showPasswordVisible ? "text" : "password"}
placeholder={t(
"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 strength indicator */}
{password && (
<div className="mt-2 space-y-1">
<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 / 3) * 100}%` }}
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>
<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>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">
{t("users.dialog.form.password.confirm.title")}
</Label>
<Input
id="confirm-password"
className="h-10"
type="password"
value={confirmPassword}
onChange={(event) => {
setConfirmPassword(event.target.value);
setError(null);
}}
placeholder={t(
"users.dialog.form.newPassword.confirm.placeholder",
<FormMessage />
</FormItem>
)}
/>
{/* Password match indicator */}
{password && confirmPassword && (
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("users.dialog.form.password.confirm.title")}
</FormLabel>
<FormControl>
<div className="relative">
<Input
{...field}
type={showConfirmPassword ? "text" : "password"}
placeholder={t(
"users.dialog.form.newPassword.confirm.placeholder",
)}
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">
{password === confirmPassword ? (
<>
<LuCheck className="size-3.5 text-green-500" />
<span className="text-green-600">
{t("users.dialog.form.password.match")}
</span>
</>
) : (
<>
<LuX className="size-3.5 text-red-500" />
<span className="text-red-600">
{t("users.dialog.form.password.notMatch")}
</span>
</>
)}
</div>
)}
</div>
{error && (
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
{form.formState.errors.root.message}
</div>
)}
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div className="flex flex-1 flex-col justify-end">
@ -204,6 +528,7 @@ export default function SetPasswordDialog({
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
disabled={isLoading}
>
{t("button.cancel", { ns: "common" })}
</Button>
@ -211,14 +536,23 @@ export default function SetPasswordDialog({
variant="select"
aria-label={t("button.save", { ns: "common" })}
className="flex flex-1"
onClick={handleSave}
disabled={!password || password !== confirmPassword}
type="submit"
disabled={isLoading || !form.formState.isValid}
>
{t("button.save", { ns: "common" })}
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@ -57,6 +57,8 @@ export default function AuthenticationView({
const [showCreateRole, setShowCreateRole] = useState(false);
const [showEditRole, setShowEditRole] = 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 [selectedUserRole, setSelectedUserRole] = useState<string>();
@ -70,12 +72,15 @@ export default function AuthenticationView({
}, [t]);
const onSavePassword = useCallback(
(user: string, password: string) => {
(user: string, password: string, oldPassword?: string) => {
setIsPasswordLoading(true);
axios
.put(`users/${user}/password`, { password })
.put(`users/${user}/password`, { password, old_password: oldPassword })
.then((response) => {
if (response.status === 200) {
setShowSetPassword(false);
setPasswordError(null);
setIsPasswordLoading(false);
toast.success(t("users.toast.success.updatePassword"), {
position: "top-center",
});
@ -86,14 +91,10 @@ export default function AuthenticationView({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("users.toast.error.setPasswordFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
// Keep dialog open and show error
setPasswordError(errorMessage);
setIsPasswordLoading(false);
});
},
[t],
@ -563,8 +564,15 @@ export default function AuthenticationView({
</div>
<SetPasswordDialog
show={showSetPassword}
onCancel={() => setShowSetPassword(false)}
onSave={(password) => onSavePassword(selectedUser!, password)}
onCancel={() => {
setShowSetPassword(false);
setPasswordError(null);
}}
initialError={passwordError}
onSave={(password, oldPassword) =>
onSavePassword(selectedUser!, password, oldPassword)
}
isLoading={isPasswordLoading}
/>
<DeleteUserDialog
show={showDelete}