Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,20 @@ FEISHU_REDIRECT_URI=http://localhost:3000/auth/feishu/callback
# Jina AI API key (for jina_search and jina_read tools — get one at https://jina.ai)
# Without a key, the tools still work but with lower rate limits
JINA_API_KEY=

# Public app URL used in user-facing links, such as password reset emails.
# Production must use your real public HTTPS domain (not localhost).
PUBLIC_BASE_URL=http://localhost:3008

# System email delivery (used for forgot-password and optional broadcast emails)
SYSTEM_EMAIL_FROM_ADDRESS=
SYSTEM_EMAIL_FROM_NAME=Clawith
SYSTEM_SMTP_HOST=
SYSTEM_SMTP_PORT=465
SYSTEM_SMTP_USERNAME=
SYSTEM_SMTP_PASSWORD=
SYSTEM_SMTP_SSL=true
SYSTEM_SMTP_TIMEOUT_SECONDS=15

# Password reset token lifetime in minutes
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES=30
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,39 @@ Agent workspace files (soul.md, memory, skills, workspace files) are stored in `

The first user to register automatically becomes the **platform admin**. Open the app, click "Register", and create your account.

### System Email and Password Reset

Clawith can send platform-owned emails for password reset and optional broadcast delivery. Configure SMTP in `.env`:

```bash
PUBLIC_BASE_URL=http://localhost:3008
SYSTEM_EMAIL_FROM_ADDRESS=bot@example.com
SYSTEM_EMAIL_FROM_NAME=Clawith
SYSTEM_SMTP_HOST=smtp.example.com
SYSTEM_SMTP_PORT=465
SYSTEM_SMTP_USERNAME=bot@example.com
SYSTEM_SMTP_PASSWORD=your-app-password
SYSTEM_SMTP_SSL=true
SYSTEM_SMTP_TIMEOUT_SECONDS=15
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES=30
```

`PUBLIC_BASE_URL` must point to the user-facing frontend because reset links are generated as `/reset-password?token=...`.
In production, set it to your public HTTPS domain (for example `https://app.example.com`), not a localhost address.

Quick local validation:

```bash
cd backend && .venv/bin/python -m pytest tests/test_password_reset_and_notifications.py
cd frontend && npm run build
```

Manual flow:
1. Open `http://localhost:3008/login`
2. Click `Forgot password?`
3. Submit a registered email
4. Open the emailed reset link and set a new password

### Network Troubleshooting

If `git clone` is slow or times out:
Expand Down
32 changes: 32 additions & 0 deletions backend/alembic/versions/add_password_reset_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Add password_reset_tokens table.

Revision ID: add_password_reset_tokens
Revises: add_daily_token_usage
"""

from alembic import op

revision = "add_password_reset_tokens"
down_revision = "add_daily_token_usage"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(128) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_password_reset_tokens_user_id ON password_reset_tokens(user_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_password_reset_tokens_token_hash ON password_reset_tokens(token_hash)")
op.execute("CREATE INDEX IF NOT EXISTS ix_password_reset_tokens_expires_at ON password_reset_tokens(expires_at)")


def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS password_reset_tokens")
87 changes: 84 additions & 3 deletions backend/app/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
"""Authentication API routes."""

import uuid
from datetime import datetime, timezone

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from loguru import logger
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.security import create_access_token, get_current_user, hash_password, verify_password
from app.database import get_db
from app.models.password_reset_token import PasswordResetToken
from app.models.user import User
from app.schemas.schemas import TokenResponse, UserLogin, UserOut, UserRegister, UserUpdate
from app.schemas.schemas import (
ForgotPasswordRequest,
ResetPasswordRequest,
TokenResponse,
UserLogin,
UserOut,
UserRegister,
UserUpdate,
)

router = APIRouter(prefix="/auth", tags=["auth"])

Expand Down Expand Up @@ -141,6 +150,78 @@ async def login(data: UserLogin, db: AsyncSession = Depends(get_db)):
)


@router.post("/forgot-password")
async def forgot_password(
data: ForgotPasswordRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""Request a password reset link without revealing account existence."""
generic_response = {
"ok": True,
"message": "If an account with that email exists, a password reset email has been sent.",
}

result = await db.execute(select(User).where(User.email == data.email))
user = result.scalar_one_or_none()
if not user or not user.is_active:
return generic_response

try:
from app.services.password_reset_service import build_password_reset_url, create_password_reset_token
from app.services.system_email_service import (
get_system_email_config,
run_background_email_job,
send_password_reset_email,
)

get_system_email_config()
raw_token, expires_at = await create_password_reset_token(db, user.id)
await db.commit()

reset_url = await build_password_reset_url(db, raw_token)
expiry_minutes = int((expires_at - datetime.now(timezone.utc)).total_seconds() // 60)
background_tasks.add_task(
run_background_email_job,
send_password_reset_email,
user.email,
user.display_name or user.username,
reset_url,
expiry_minutes,
)
except Exception as exc:
logger.warning(f"Failed to process password reset email for {data.email}: {exc}")

return generic_response


@router.post("/reset-password")
async def reset_password(data: ResetPasswordRequest, db: AsyncSession = Depends(get_db)):
"""Reset a password using a valid single-use token."""
from app.services.password_reset_service import consume_password_reset_token

token = await consume_password_reset_token(db, data.token)
if not token:
raise HTTPException(status_code=400, detail="Invalid or expired reset token")

result = await db.execute(select(User).where(User.id == token.user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(status_code=400, detail="Invalid or expired reset token")

user.password_hash = hash_password(data.new_password)

# Invalidate any other older token rows for the same user.
other_tokens = await db.execute(select(PasswordResetToken).where(PasswordResetToken.user_id == user.id))
now = datetime.now(timezone.utc)
for row in other_tokens.scalars().all():
if row.id != token.id and row.used_at is None:
row.used_at = now

await db.flush()
return {"ok": True}


@router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)):
"""Get current user profile."""
Expand Down
51 changes: 47 additions & 4 deletions backend/app/api/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import uuid
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
Expand Down Expand Up @@ -116,11 +116,13 @@ async def mark_all_read(
class BroadcastRequest(BaseModel):
title: str = Field(..., max_length=200)
body: str = Field("", max_length=1000)
send_email: bool = False


@router.post("/notifications/broadcast")
async def broadcast_notification(
req: BroadcastRequest,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
Expand All @@ -138,12 +140,23 @@ async def broadcast_notification(
sender_name = current_user.display_name or current_user.username or "Admin"
count_users = 0
count_agents = 0
count_emails = 0
email_recipients = []

if req.send_email:
from app.services.system_email_service import get_system_email_config

try:
get_system_email_config()
except Exception as exc:
raise HTTPException(400, f"System email is not configured: {exc}")

# Notify all users in tenant
users_result = await db.execute(
select(User).where(User.tenant_id == tenant_id, User.id != current_user.id)
)
for user in users_result.scalars().all():
users = users_result.scalars().all()
for user in users:
await send_notification(
db, user_id=user.id,
type="broadcast",
Expand All @@ -167,6 +180,36 @@ async def broadcast_notification(
)
count_agents += 1

await db.commit()
return {"ok": True, "users_notified": count_users, "agents_notified": count_agents}
if req.send_email:
from app.services.system_email_service import (
BroadcastEmailRecipient,
deliver_broadcast_emails,
run_background_email_job,
)

for user in users:
if not user.email:
continue
email_recipients.append(
BroadcastEmailRecipient(
email=user.email,
subject=req.title,
body=(
f"{req.body}\n\n"
f"Sent by: {sender_name}"
if req.body.strip()
else f"Sent by: {sender_name}"
),
),
)
count_emails += 1

await db.commit()
if email_recipients:
background_tasks.add_task(run_background_email_job, deliver_broadcast_emails, email_recipients)
return {
"ok": True,
"users_notified": count_users,
"agents_notified": count_agents,
"emails_sent": count_emails,
}
12 changes: 12 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,22 @@ class Settings(BaseSettings):
JWT_SECRET_KEY: str = "change-me-jwt-secret"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24 hours
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = 60

# File Storage
AGENT_DATA_DIR: str = _default_agent_data_dir()
AGENT_TEMPLATE_DIR: str = "/app/agent_template"

# System email (platform-owned outbound mail)
SYSTEM_EMAIL_FROM_ADDRESS: str = ""
SYSTEM_EMAIL_FROM_NAME: str = "Clawith"
SYSTEM_SMTP_HOST: str = ""
SYSTEM_SMTP_PORT: int = 465
SYSTEM_SMTP_USERNAME: str = ""
SYSTEM_SMTP_PASSWORD: str = ""
SYSTEM_SMTP_SSL: bool = True
SYSTEM_SMTP_TIMEOUT_SECONDS: int = 15

# Docker (for Agent containers)
DOCKER_NETWORK: str = "clawith_network"
OPENCLAW_IMAGE: str = "openclaw:local"
Expand All @@ -78,6 +89,7 @@ class Settings(BaseSettings):
FEISHU_APP_ID: str = ""
FEISHU_APP_SECRET: str = ""
FEISHU_REDIRECT_URI: str = ""
PUBLIC_BASE_URL: str = ""

# CORS
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"]
Expand Down
1 change: 1 addition & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ async def lifespan(app: FastAPI):
import app.models.trigger # noqa
import app.models.notification # noqa
import app.models.gateway_message # noqa
import app.models.password_reset_token # noqa
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("[startup] Database tables ready")
Expand Down
23 changes: 23 additions & 0 deletions backend/app/models/password_reset_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Password reset token model."""

import uuid
from datetime import datetime

from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column

from app.database import Base


class PasswordResetToken(Base):
"""Single-use password reset token."""

__tablename__ = "password_reset_tokens"

id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
10 changes: 9 additions & 1 deletion backend/app/schemas/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ class UserLogin(BaseModel):
password: str


class ForgotPasswordRequest(BaseModel):
email: EmailStr


class ResetPasswordRequest(BaseModel):
token: str = Field(min_length=20, max_length=512)
new_password: str = Field(min_length=6, max_length=128)


class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
Expand Down Expand Up @@ -441,4 +450,3 @@ class GatewaySendMessageRequest(BaseModel):
target: str # Name of target person or agent
content: str = Field(min_length=1)
channel: str | None = None # Optional: "feishu", "agent", etc. Auto-detected if omitted.

Loading