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 @@ -155,6 +155,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: multi_tenant_registration
"""

from alembic import op

revision = "add_password_reset_tokens"
down_revision = "multi_tenant_registration"
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")
62 changes: 62 additions & 0 deletions backend/alembic/versions/add_wecom_org_sync_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Add provider-aware org sync fields.

Revision ID: add_wecom_org_sync_fields
Revises: add_password_reset_tokens
"""

from alembic import op

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


def upgrade() -> None:
op.execute(
"""
ALTER TABLE org_departments
ADD COLUMN IF NOT EXISTS wecom_id VARCHAR(100)
"""
)
op.execute(
"""
ALTER TABLE org_departments
ADD COLUMN IF NOT EXISTS sync_provider VARCHAR(20) NOT NULL DEFAULT 'feishu'
"""
)
op.execute(
"""
ALTER TABLE org_members
ADD COLUMN IF NOT EXISTS wecom_user_id VARCHAR(100)
"""
)
op.execute(
"""
ALTER TABLE org_members
ADD COLUMN IF NOT EXISTS sync_provider VARCHAR(20) NOT NULL DEFAULT 'feishu'
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_org_departments_wecom_id ON org_departments(wecom_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_org_departments_sync_provider ON org_departments(sync_provider)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_org_members_wecom_user_id ON org_members(wecom_user_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_org_members_sync_provider ON org_members(sync_provider)"
)


def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_org_members_sync_provider")
op.execute("DROP INDEX IF EXISTS ix_org_members_wecom_user_id")
op.execute("DROP INDEX IF EXISTS ix_org_departments_sync_provider")
op.execute("DROP INDEX IF EXISTS ix_org_departments_wecom_id")
op.execute("ALTER TABLE org_members DROP COLUMN IF EXISTS sync_provider")
op.execute("ALTER TABLE org_members DROP COLUMN IF EXISTS wecom_user_id")
op.execute("ALTER TABLE org_departments DROP COLUMN IF EXISTS sync_provider")
op.execute("ALTER TABLE org_departments DROP COLUMN IF EXISTS wecom_id")
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
Loading