mirror of
https://github.com/dagu-org/dagu.git
synced 2025-12-28 06:34:22 +00:00
feat(all): implement builtin user management feature (#1463)
* **New Features** * Built-in RBAC auth with JWT login, token handling, and user lifecycle APIs (list/create/view/update/delete, reset/change password). * **UI** * Login flow, protected routes, Users management page, change/reset-password modals, user menu and role-aware navigation. * **Behavior** * v1 routes disabled when auth enabled; runtime config exposes authMode and usersDir; client persists auth token. * **Documentation** * Added builtin auth docs and new env/config options. * **Tests** * Extensive tests for auth service, file-backed store, and API handlers.
This commit is contained in:
parent
b4c857d7b4
commit
d4b8484ca8
21
README.md
21
README.md
@ -44,7 +44,7 @@ Built for developers who want powerful workflow orchestration without the operat
|
||||
**Built-in queue system with intelligent task routing.** Route tasks to workers based on labels (GPU, region, compliance requirements). Automatic service registry and health monitoring included—no external coordination service needed.
|
||||
|
||||
### 🎯 Production Ready
|
||||
**Not a toy.** Battle-tested error handling with exponential backoff retries, lifecycle hooks (onSuccess, onFailure, onExit), real-time log streaming, email notifications, Prometheus metrics, and OpenTelemetry tracing out of the box.
|
||||
**Not a toy.** Battle-tested error handling with exponential backoff retries, lifecycle hooks (onSuccess, onFailure, onExit), real-time log streaming, email notifications, Prometheus metrics, and OpenTelemetry tracing out of the box. Built-in user management with role-based access control (RBAC) for team environments.
|
||||
|
||||
### 🎨 Modern Web UI
|
||||
**Beautiful UI that actually helps you debug.** Live log tailing, DAG visualization with Gantt charts, execution history with full lineage, and drill-down into nested sub-workflows. Dark mode included.
|
||||
@ -252,6 +252,7 @@ Full documentation is available at [docs.dagu.cloud](https://docs.dagu.cloud/).
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `DAGU_AUTH_MODE` | - | Authentication mode: `none`, `builtin`, or `oidc` |
|
||||
| `DAGU_AUTH_BASIC_USERNAME` | - | Basic auth username |
|
||||
| `DAGU_AUTH_BASIC_PASSWORD` | - | Basic auth password |
|
||||
| `DAGU_AUTH_OIDC_CLIENT_ID` | - | OIDC client ID |
|
||||
@ -261,6 +262,18 @@ Full documentation is available at [docs.dagu.cloud](https://docs.dagu.cloud/).
|
||||
| `DAGU_AUTH_OIDC_SCOPES` | - | OIDC scopes (comma-separated) |
|
||||
| `DAGU_AUTH_OIDC_WHITELIST` | - | OIDC email whitelist (comma-separated) |
|
||||
|
||||
### Builtin Authentication (RBAC)
|
||||
|
||||
When `DAGU_AUTH_MODE=builtin`, a file-based user management system with role-based access control is enabled. Roles: `admin`, `manager`, `operator`, `viewer`.
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `DAGU_AUTH_ADMIN_USERNAME` | `admin` | Initial admin username |
|
||||
| `DAGU_AUTH_ADMIN_PASSWORD` | (auto-generated) | Initial admin password |
|
||||
| `DAGU_AUTH_TOKEN_SECRET` | - | JWT token secret for signing (required) |
|
||||
| `DAGU_AUTH_TOKEN_TTL` | `24h` | JWT token time-to-live |
|
||||
| `DAGU_USERS_DIR` | `{dataDir}/users` | Directory for user data files |
|
||||
|
||||
### UI Configuration
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
@ -404,7 +417,9 @@ This section outlines the current capabilities of Dagu.
|
||||
| | Health monitoring | Health check for scheduler & failover | <a href="https://docs.dagu.cloud/configurations/reference#health-check">Health Check</a> |
|
||||
| | Nested-DAG visualization | Nested DAG visualization with drill down functionality | <a href="https://docs.dagu.cloud/overview/web-ui#nested-dag-visualization">Nested DAG Visualization</a> |
|
||||
| Security & Governance | Secret injection | Vault/KMS/OIDC ref-only; short-lived tokens | <a href="https://docs.dagu.cloud/writing-workflows/secrets">Secrets</a> |
|
||||
| | Authentication | Basic auth / OIDC support for Web UI and API | <a href="https://docs.dagu.cloud/configurations/authentication">Authentication</a> |
|
||||
| | Authentication | Basic auth / OIDC / Builtin (JWT) support for Web UI and API | <a href="https://docs.dagu.cloud/configurations/authentication">Authentication</a> |
|
||||
| | Role-based access control | Builtin RBAC with admin, manager, operator, viewer roles | |
|
||||
| | User management | Create, update, delete users with role assignment | |
|
||||
| | HA (High availability) mode | Control-plane with failover for scheduler / Web UI / Coordinator | <a href="https://docs.dagu.cloud/features/scheduling#high-availability">High Availability</a> |
|
||||
| Executor types | `jq` | JSON processing with jq queries | <a href="https://docs.dagu.cloud/features/executors/jq">JQ Executor</a> |
|
||||
| | `ssh` | Remote command execution via SSH | <a href="https://docs.dagu.cloud/features/executors/ssh">SSH Executor</a> |
|
||||
@ -457,7 +472,7 @@ This section outlines the planned features for Dagu.
|
||||
| | Inter DAG-run state management | Manage state and data sharing between DAG-runs | 💭 | P0 | |
|
||||
| | Database backend support | Support for external databases (PostgreSQL, MySQL) instead of filesystem | 💭 | P1 | <a href="https://github.com/dagu-org/dagu/issues/539">#539</a>, <a href="https://github.com/dagu-org/dagu/issues/267">#267</a> |
|
||||
| Observability | Resource usage monitoring | CPU/Memory/IO usage per DAG/step with live graphs | 💭 | P0 | <a href="https://github.com/dagu-org/dagu/issues/546">#546</a> |
|
||||
| Security & Governance | Authorization | User management & RBAC with fine-grained permissions | 🏢 | | |
|
||||
| Security & Governance | Fine-grained permissions | DAG-level and resource-level permissions | 🏢 | | |
|
||||
| | Resource quotas | CPU time and memory limit | 📋 | P0 | |
|
||||
| | Audit trail | Immutable events for all manual actions | 🏢 | | |
|
||||
| | Audit logging | Immutable who/what/when records (WORM) | 🏢 | | |
|
||||
|
||||
1601
api/v2/api.gen.go
1601
api/v2/api.gen.go
File diff suppressed because it is too large
Load Diff
555
api/v2/api.yaml
555
api/v2/api.yaml
@ -33,6 +33,10 @@ tags:
|
||||
description: "Prometheus-compatible metrics for monitoring Dagu operations"
|
||||
- name: "queues"
|
||||
description: "Operations for managing and monitoring execution queues"
|
||||
- name: "auth"
|
||||
description: "Authentication operations (login, logout, token management)"
|
||||
- name: "users"
|
||||
description: "User management operations (CRUD, password management)"
|
||||
|
||||
paths:
|
||||
/health:
|
||||
@ -52,6 +56,385 @@ paths:
|
||||
default:
|
||||
description: "Unexpected error"
|
||||
|
||||
# ============================================================================
|
||||
# Authentication Endpoints
|
||||
# ============================================================================
|
||||
|
||||
/auth/login:
|
||||
post:
|
||||
summary: "Authenticate user and obtain JWT token"
|
||||
description: "Authenticates a user with username and password, returns a JWT token on success"
|
||||
operationId: "login"
|
||||
tags:
|
||||
- "auth"
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/LoginRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: "Authentication successful"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/LoginResponse"
|
||||
"401":
|
||||
description: "Invalid credentials"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
default:
|
||||
description: "Unexpected error"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/auth/me:
|
||||
get:
|
||||
summary: "Get current authenticated user"
|
||||
description: "Returns information about the currently authenticated user"
|
||||
operationId: "getCurrentUser"
|
||||
tags:
|
||||
- "auth"
|
||||
responses:
|
||||
"200":
|
||||
description: "Current user information"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserResponse"
|
||||
"401":
|
||||
description: "Not authenticated"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
default:
|
||||
description: "Unexpected error"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/auth/change-password:
|
||||
post:
|
||||
summary: "Change current user's password"
|
||||
description: "Allows the authenticated user to change their own password"
|
||||
operationId: "changePassword"
|
||||
tags:
|
||||
- "auth"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ChangePasswordRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: "Password changed successfully"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuccessResponse"
|
||||
"400":
|
||||
description: "Invalid request (e.g., weak password)"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"401":
|
||||
description: "Not authenticated or wrong current password"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
default:
|
||||
description: "Unexpected error"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
# ============================================================================
|
||||
# User Management Endpoints (Admin only)
|
||||
# ============================================================================
|
||||
|
||||
/users:
|
||||
get:
|
||||
summary: "List all users"
|
||||
description: "Returns a list of all users. Requires admin role."
|
||||
operationId: "listUsers"
|
||||
tags:
|
||||
- "users"
|
||||
responses:
|
||||
"200":
|
||||
description: "List of users"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UsersListResponse"
|
||||
"401":
|
||||
description: "Not authenticated"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"403":
|
||||
description: "Forbidden - requires admin role"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
default:
|
||||
description: "Unexpected error"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
post:
|
||||
summary: "Create a new user"
|
||||
description: "Creates a new user account. Requires admin role."
|
||||
operationId: "createUser"
|
||||
tags:
|
||||
- "users"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateUserRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: "User created successfully"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserResponse"
|
||||
"400":
|
||||
description: "Invalid request (e.g., weak password, invalid role)"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"401":
|
||||
description: "Not authenticated"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"403":
|
||||
description: "Forbidden - requires admin role"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"409":
|
||||
description: "Conflict - username already exists"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
default:
|
||||
description: "Unexpected error"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/users/{userId}:
|
||||
get:
|
||||
summary: "Get user by ID"
|
||||
description: "Returns a specific user by their ID. Requires admin role."
|
||||
operationId: "getUser"
|
||||
tags:
|
||||
- "users"
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/UserId"
|
||||
responses:
|
||||
"200":
|
||||
description: "User details"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserResponse"
|
||||
"401":
|
||||
description: "Not authenticated"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"403":
|
||||
description: "Forbidden - requires admin role"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"404":
|
||||
description: "User not found"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
default:
|
||||
description: "Unexpected error"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
patch:
|
||||
summary: "Update user"
|
||||
description: "Updates a user's information. Requires admin role."
|
||||
operationId: "updateUser"
|
||||
tags:
|
||||
- "users"
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/UserId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateUserRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: "User updated successfully"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserResponse"
|
||||
"400":
|
||||
description: "Invalid request"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"401":
|
||||
description: "Not authenticated"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"403":
|
||||
description: "Forbidden - requires admin role"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"404":
|
||||
description: "User not found"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"409":
|
||||
description: "Conflict - username already exists"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
default:
|
||||
description: "Unexpected error"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
delete:
|
||||
summary: "Delete user"
|
||||
description: "Deletes a user account. Requires admin role. Cannot delete yourself."
|
||||
operationId: "deleteUser"
|
||||
tags:
|
||||
- "users"
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/UserId"
|
||||
responses:
|
||||
"204":
|
||||
description: "User deleted successfully"
|
||||
"401":
|
||||
description: "Not authenticated"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"403":
|
||||
description: "Forbidden - requires admin role or cannot delete self"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"404":
|
||||
description: "User not found"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
default:
|
||||
description: "Unexpected error"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/users/{userId}/reset-password:
|
||||
post:
|
||||
summary: "Reset user's password"
|
||||
description: "Resets a user's password to a new value. Requires admin role."
|
||||
operationId: "resetUserPassword"
|
||||
tags:
|
||||
- "users"
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/UserId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ResetPasswordRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: "Password reset successfully"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuccessResponse"
|
||||
"400":
|
||||
description: "Invalid request (e.g., weak password)"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"401":
|
||||
description: "Not authenticated"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"403":
|
||||
description: "Forbidden - requires admin role"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"404":
|
||||
description: "User not found"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
default:
|
||||
description: "Unexpected error"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/workers:
|
||||
get:
|
||||
summary: "List distributed workers"
|
||||
@ -1673,6 +2056,15 @@ components:
|
||||
minimum: 1
|
||||
default: 1
|
||||
|
||||
UserId:
|
||||
name: userId
|
||||
in: path
|
||||
description: unique identifier of the user
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
|
||||
PerPage:
|
||||
name: perPage
|
||||
in: query
|
||||
@ -2870,6 +3262,169 @@ components:
|
||||
- timestamp
|
||||
- value
|
||||
|
||||
# ============================================================================
|
||||
# Authentication & User Management Schemas
|
||||
# ============================================================================
|
||||
|
||||
UserRole:
|
||||
type: string
|
||||
description: "User role determining access permissions. admin: full access including user management, manager: DAG CRUD and execution, operator: DAG execution only, viewer: read-only"
|
||||
enum:
|
||||
- admin
|
||||
- manager
|
||||
- operator
|
||||
- viewer
|
||||
|
||||
LoginRequest:
|
||||
type: object
|
||||
description: "Request body for user login"
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: "User's username"
|
||||
minLength: 1
|
||||
password:
|
||||
type: string
|
||||
description: "User's password"
|
||||
minLength: 1
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
|
||||
LoginResponse:
|
||||
type: object
|
||||
description: "Response containing authentication token"
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: "JWT authentication token"
|
||||
expiresAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Token expiration timestamp"
|
||||
user:
|
||||
$ref: "#/components/schemas/User"
|
||||
required:
|
||||
- token
|
||||
- expiresAt
|
||||
- user
|
||||
|
||||
ChangePasswordRequest:
|
||||
type: object
|
||||
description: "Request body for changing password"
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
description: "Current password for verification"
|
||||
minLength: 1
|
||||
newPassword:
|
||||
type: string
|
||||
description: "New password to set"
|
||||
minLength: 8
|
||||
required:
|
||||
- currentPassword
|
||||
- newPassword
|
||||
|
||||
ResetPasswordRequest:
|
||||
type: object
|
||||
description: "Request body for admin password reset"
|
||||
properties:
|
||||
newPassword:
|
||||
type: string
|
||||
description: "New password to set for the user"
|
||||
minLength: 8
|
||||
required:
|
||||
- newPassword
|
||||
|
||||
CreateUserRequest:
|
||||
type: object
|
||||
description: "Request body for creating a new user"
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: "Unique username"
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
password:
|
||||
type: string
|
||||
description: "User's password"
|
||||
minLength: 8
|
||||
role:
|
||||
$ref: "#/components/schemas/UserRole"
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
- role
|
||||
|
||||
UpdateUserRequest:
|
||||
type: object
|
||||
description: "Request body for updating a user"
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: "New username (must be unique)"
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
role:
|
||||
$ref: "#/components/schemas/UserRole"
|
||||
|
||||
User:
|
||||
type: object
|
||||
description: "User information"
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: "Unique user identifier"
|
||||
username:
|
||||
type: string
|
||||
description: "User's username"
|
||||
role:
|
||||
$ref: "#/components/schemas/UserRole"
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Account creation timestamp"
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Last update timestamp"
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
- role
|
||||
- createdAt
|
||||
- updatedAt
|
||||
|
||||
UserResponse:
|
||||
type: object
|
||||
description: "Response containing user information"
|
||||
properties:
|
||||
user:
|
||||
$ref: "#/components/schemas/User"
|
||||
required:
|
||||
- user
|
||||
|
||||
UsersListResponse:
|
||||
type: object
|
||||
description: "Response containing list of users"
|
||||
properties:
|
||||
users:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/User"
|
||||
required:
|
||||
- users
|
||||
|
||||
SuccessResponse:
|
||||
type: object
|
||||
description: "Generic success response"
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
description: "Success message"
|
||||
required:
|
||||
- message
|
||||
|
||||
# Apply security requirements globally
|
||||
security:
|
||||
- apiToken: []
|
||||
|
||||
@ -23,6 +23,13 @@ services:
|
||||
- DAGU_DEBUG=true # More verbose logs during dev
|
||||
# Paths
|
||||
- DAGU_DAGS_DIR=/var/lib/dagu/dags
|
||||
# Builtin authentication (RBAC) - CHANGE TOKEN_SECRET IN PRODUCTION
|
||||
- DAGU_AUTH_MODE=builtin
|
||||
- DAGU_AUTH_TOKEN_SECRET=CHANGE_ME_TO_A_SECURE_RANDOM_STRING
|
||||
# Admin credentials: password auto-generated on first run, printed to stdout
|
||||
# - DAGU_AUTH_ADMIN_USERNAME=admin # default is 'admin'
|
||||
# - DAGU_AUTH_ADMIN_PASSWORD= # set to use a specific password
|
||||
# - DAGU_AUTH_TOKEN_TTL=24h # default is 24h
|
||||
# Timezone / base path (optional)
|
||||
# - DAGU_TZ=UTC
|
||||
# - DAGU_BASE_PATH=/dagu
|
||||
@ -43,7 +50,9 @@ services:
|
||||
# command: ["dagu", "coordinator"]
|
||||
# command: ["dagu", "worker"]
|
||||
|
||||
# Example: enable basic auth in dev (uncomment and set values)
|
||||
# Alternative: basic auth (simpler, no user management)
|
||||
# To use basic auth instead of builtin auth, comment out
|
||||
# DAGU_AUTH_MODE and DAGU_AUTH_TOKEN_SECRET above, then uncomment:
|
||||
# environment:
|
||||
# - DAGU_AUTH_BASIC_USERNAME=dev
|
||||
# - DAGU_AUTH_BASIC_PASSWORD=devpass
|
||||
|
||||
@ -82,6 +82,13 @@ services:
|
||||
- DAGU_HOST=0.0.0.0
|
||||
- DAGU_PORT=8080
|
||||
- DAGU_DAGS_DIR=/var/lib/dagu/dags
|
||||
# Builtin authentication (RBAC) - CHANGE TOKEN_SECRET IN PRODUCTION
|
||||
- DAGU_AUTH_MODE=builtin
|
||||
- DAGU_AUTH_TOKEN_SECRET=CHANGE_ME_TO_A_SECURE_RANDOM_STRING
|
||||
# Admin credentials: password auto-generated on first run, printed to stdout
|
||||
# - DAGU_AUTH_ADMIN_USERNAME=admin # default is 'admin'
|
||||
# - DAGU_AUTH_ADMIN_PASSWORD= # set to use a specific password
|
||||
# - DAGU_AUTH_TOKEN_TTL=24h # default is 24h
|
||||
# If behind a proxy, set base path
|
||||
# - DAGU_BASE_PATH=/dagu
|
||||
depends_on:
|
||||
|
||||
26
internal/auth/context.go
Normal file
26
internal/auth/context.go
Normal file
@ -0,0 +1,26 @@
|
||||
// Copyright (C) 2024 Yota Hamada
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
// contextKey is a private type for context keys to avoid collisions.
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
// userContextKey is the key for storing the authenticated user in context.
|
||||
userContextKey contextKey = "auth_user"
|
||||
)
|
||||
|
||||
// WithUser returns a new context that carries the provided user value.
|
||||
func WithUser(ctx context.Context, user *User) context.Context {
|
||||
return context.WithValue(ctx, userContextKey, user)
|
||||
}
|
||||
|
||||
// UserFromContext retrieves the authenticated user from the context.
|
||||
// It returns the user and true if a *User value is present for the package's userContextKey, or nil and false otherwise.
|
||||
func UserFromContext(ctx context.Context) (*User, bool) {
|
||||
user, ok := ctx.Value(userContextKey).(*User)
|
||||
return user, ok
|
||||
}
|
||||
77
internal/auth/role.go
Normal file
77
internal/auth/role.go
Normal file
@ -0,0 +1,77 @@
|
||||
// Copyright (C) 2024 Yota Hamada
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package auth
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Role represents a user's role in the system.
|
||||
// Roles determine what actions a user can perform.
|
||||
//
|
||||
// Role hierarchy (most to least privileged):
|
||||
// - admin: Full system access including user management
|
||||
// - manager: Can create, edit, delete, run, and stop DAGs
|
||||
// - operator: Can run and stop DAGs (execute only)
|
||||
// - viewer: Read-only access to DAGs and status
|
||||
type Role string
|
||||
|
||||
const (
|
||||
// RoleAdmin has full access to all resources including user management.
|
||||
RoleAdmin Role = "admin"
|
||||
// RoleManager can create, edit, delete, run, and stop DAGs.
|
||||
RoleManager Role = "manager"
|
||||
// RoleOperator can run and stop DAGs (execute only, no edit).
|
||||
RoleOperator Role = "operator"
|
||||
// RoleViewer can only view DAGs and execution history (read-only).
|
||||
RoleViewer Role = "viewer"
|
||||
)
|
||||
|
||||
// allRoles contains all valid roles for iteration and validation.
|
||||
var allRoles = []Role{RoleAdmin, RoleManager, RoleOperator, RoleViewer}
|
||||
|
||||
// AllRoles returns a copy of all valid roles.
|
||||
func AllRoles() []Role {
|
||||
roles := make([]Role, len(allRoles))
|
||||
copy(roles, allRoles)
|
||||
return roles
|
||||
}
|
||||
|
||||
// Valid returns true if the role is a known valid role.
|
||||
func (r Role) Valid() bool {
|
||||
switch r {
|
||||
case RoleAdmin, RoleManager, RoleOperator, RoleViewer:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// String returns the string representation of the role.
|
||||
func (r Role) String() string {
|
||||
return string(r)
|
||||
}
|
||||
|
||||
// CanWrite returns true if the role can create, edit, or delete DAGs.
|
||||
func (r Role) CanWrite() bool {
|
||||
return r == RoleAdmin || r == RoleManager
|
||||
}
|
||||
|
||||
// CanExecute returns true if the role can run or stop DAGs.
|
||||
func (r Role) CanExecute() bool {
|
||||
return r == RoleAdmin || r == RoleManager || r == RoleOperator
|
||||
}
|
||||
|
||||
// IsAdmin returns true if the role has administrative privileges (user management).
|
||||
func (r Role) IsAdmin() bool {
|
||||
return r == RoleAdmin
|
||||
}
|
||||
|
||||
// ParseRole converts a string to a Role.
|
||||
// ParseRole converts the input string to a Role and verifies it is one of the known roles.
|
||||
// If the input is not "admin", "manager", "operator", or "viewer", it returns an error describing the valid options.
|
||||
func ParseRole(s string) (Role, error) {
|
||||
role := Role(s)
|
||||
if !role.Valid() {
|
||||
return "", fmt.Errorf("invalid role: %q, must be one of: admin, manager, operator, viewer", s)
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
136
internal/auth/role_test.go
Normal file
136
internal/auth/role_test.go
Normal file
@ -0,0 +1,136 @@
|
||||
// Copyright (C) 2024 Yota Hamada
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRole_Valid(t *testing.T) {
|
||||
tests := []struct {
|
||||
role Role
|
||||
valid bool
|
||||
}{
|
||||
{RoleAdmin, true},
|
||||
{RoleManager, true},
|
||||
{RoleOperator, true},
|
||||
{RoleViewer, true},
|
||||
{Role("invalid"), false},
|
||||
{Role(""), false},
|
||||
{Role("ADMIN"), false}, // case sensitive
|
||||
{Role("editor"), false}, // old role name
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.role), func(t *testing.T) {
|
||||
if got := tt.role.Valid(); got != tt.valid {
|
||||
t.Errorf("Role(%q).Valid() = %v, want %v", tt.role, got, tt.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRole_CanWrite(t *testing.T) {
|
||||
tests := []struct {
|
||||
role Role
|
||||
canWrite bool
|
||||
}{
|
||||
{RoleAdmin, true},
|
||||
{RoleManager, true},
|
||||
{RoleOperator, false},
|
||||
{RoleViewer, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.role), func(t *testing.T) {
|
||||
if got := tt.role.CanWrite(); got != tt.canWrite {
|
||||
t.Errorf("Role(%q).CanWrite() = %v, want %v", tt.role, got, tt.canWrite)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRole_CanExecute(t *testing.T) {
|
||||
tests := []struct {
|
||||
role Role
|
||||
canExecute bool
|
||||
}{
|
||||
{RoleAdmin, true},
|
||||
{RoleManager, true},
|
||||
{RoleOperator, true},
|
||||
{RoleViewer, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.role), func(t *testing.T) {
|
||||
if got := tt.role.CanExecute(); got != tt.canExecute {
|
||||
t.Errorf("Role(%q).CanExecute() = %v, want %v", tt.role, got, tt.canExecute)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRole_IsAdmin(t *testing.T) {
|
||||
tests := []struct {
|
||||
role Role
|
||||
isAdmin bool
|
||||
}{
|
||||
{RoleAdmin, true},
|
||||
{RoleManager, false},
|
||||
{RoleOperator, false},
|
||||
{RoleViewer, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.role), func(t *testing.T) {
|
||||
if got := tt.role.IsAdmin(); got != tt.isAdmin {
|
||||
t.Errorf("Role(%q).IsAdmin() = %v, want %v", tt.role, got, tt.isAdmin)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRole(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want Role
|
||||
wantErr bool
|
||||
}{
|
||||
{"admin", RoleAdmin, false},
|
||||
{"manager", RoleManager, false},
|
||||
{"operator", RoleOperator, false},
|
||||
{"viewer", RoleViewer, false},
|
||||
{"invalid", "", true},
|
||||
{"", "", true},
|
||||
{"ADMIN", "", true},
|
||||
{"editor", "", true}, // old role name
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, err := ParseRole(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseRole(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("ParseRole(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllRoles(t *testing.T) {
|
||||
roles := AllRoles()
|
||||
if len(roles) != 4 {
|
||||
t.Errorf("AllRoles() returned %d roles, want 4", len(roles))
|
||||
}
|
||||
|
||||
// Ensure modifying returned slice doesn't affect internal state
|
||||
roles[0] = "modified"
|
||||
originalRoles := AllRoles()
|
||||
if originalRoles[0] == "modified" {
|
||||
t.Error("AllRoles() returned a reference to internal state")
|
||||
}
|
||||
}
|
||||
52
internal/auth/store.go
Normal file
52
internal/auth/store.go
Normal file
@ -0,0 +1,52 @@
|
||||
// Copyright (C) 2024 Yota Hamada
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Common errors for user store operations.
|
||||
var (
|
||||
// ErrUserNotFound is returned when a user cannot be found.
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
// ErrUserAlreadyExists is returned when attempting to create a user
|
||||
// with a username that already exists.
|
||||
ErrUserAlreadyExists = errors.New("user already exists")
|
||||
// ErrInvalidUsername is returned when the username is invalid.
|
||||
ErrInvalidUsername = errors.New("invalid username")
|
||||
// ErrInvalidUserID is returned when the user ID is invalid.
|
||||
ErrInvalidUserID = errors.New("invalid user ID")
|
||||
)
|
||||
|
||||
// UserStore defines the interface for user persistence operations.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type UserStore interface {
|
||||
// Create stores a new user.
|
||||
// Returns ErrUserAlreadyExists if a user with the same username exists.
|
||||
Create(ctx context.Context, user *User) error
|
||||
|
||||
// GetByID retrieves a user by their unique ID.
|
||||
// Returns ErrUserNotFound if the user does not exist.
|
||||
GetByID(ctx context.Context, id string) (*User, error)
|
||||
|
||||
// GetByUsername retrieves a user by their username.
|
||||
// Returns ErrUserNotFound if the user does not exist.
|
||||
GetByUsername(ctx context.Context, username string) (*User, error)
|
||||
|
||||
// List returns all users in the store.
|
||||
List(ctx context.Context) ([]*User, error)
|
||||
|
||||
// Update modifies an existing user.
|
||||
// Returns ErrUserNotFound if the user does not exist.
|
||||
Update(ctx context.Context, user *User) error
|
||||
|
||||
// Delete removes a user by their ID.
|
||||
// Returns ErrUserNotFound if the user does not exist.
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Count returns the total number of users.
|
||||
Count(ctx context.Context) (int64, error)
|
||||
}
|
||||
76
internal/auth/user.go
Normal file
76
internal/auth/user.go
Normal file
@ -0,0 +1,76 @@
|
||||
// Copyright (C) 2024 Yota Hamada
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// User represents a user in the system.
|
||||
type User struct {
|
||||
// ID is the unique identifier for the user (UUID).
|
||||
ID string `json:"id"`
|
||||
// Username is the unique login name.
|
||||
Username string `json:"username"`
|
||||
// PasswordHash is the bcrypt hash of the password.
|
||||
// Excluded from JSON serialization for security.
|
||||
PasswordHash string `json:"-"`
|
||||
// Role determines the user's permissions.
|
||||
Role Role `json:"role"`
|
||||
// CreatedAt is the timestamp when the user was created.
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// UpdatedAt is the timestamp when the user was last modified.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NewUser creates a User with a new UUID and sets CreatedAt and UpdatedAt to the current UTC time.
|
||||
// The provided username, passwordHash, and role are assigned to the corresponding fields.
|
||||
func NewUser(username string, passwordHash string, role Role) *User {
|
||||
now := time.Now().UTC()
|
||||
return &User{
|
||||
ID: uuid.New().String(),
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
Role: role,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// UserForStorage is used for JSON serialization to persistent storage.
|
||||
// It includes the password hash which is excluded from the regular User JSON.
|
||||
type UserForStorage struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Role Role `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ToStorage converts a User to UserForStorage for persistence.
|
||||
func (u *User) ToStorage() *UserForStorage {
|
||||
return &UserForStorage{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
PasswordHash: u.PasswordHash,
|
||||
Role: u.Role,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToUser converts UserForStorage back to User.
|
||||
func (s *UserForStorage) ToUser() *User {
|
||||
return &User{
|
||||
ID: s.ID,
|
||||
Username: s.Username,
|
||||
PasswordHash: s.PasswordHash,
|
||||
Role: s.Role,
|
||||
CreatedAt: s.CreatedAt,
|
||||
UpdatedAt: s.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@ -196,7 +196,7 @@ func (c *Context) NewServer(rs *resource.Service) (*frontend.Server, error) {
|
||||
|
||||
mr := telemetry.NewRegistry(collector)
|
||||
|
||||
return frontend.NewServer(c.Config, dr, c.DAGRunStore, c.QueueStore, c.ProcStore, c.DAGRunMgr, cc, c.ServiceRegistry, mr, rs), nil
|
||||
return frontend.NewServer(c.Config, dr, c.DAGRunStore, c.QueueStore, c.ProcStore, c.DAGRunMgr, cc, c.ServiceRegistry, mr, rs)
|
||||
}
|
||||
|
||||
// NewCoordinatorClient creates a new coordinator client using the global peer configuration.
|
||||
|
||||
@ -127,11 +127,43 @@ const (
|
||||
PermissionRunDAGs Permission = "run_dags"
|
||||
)
|
||||
|
||||
// AuthMode represents the authentication mode.
|
||||
type AuthMode string
|
||||
|
||||
const (
|
||||
// AuthModeNone disables authentication.
|
||||
AuthModeNone AuthMode = "none"
|
||||
// AuthModeBuiltin enables builtin user management with RBAC.
|
||||
AuthModeBuiltin AuthMode = "builtin"
|
||||
// AuthModeOIDC enables OIDC authentication.
|
||||
AuthModeOIDC AuthMode = "oidc"
|
||||
)
|
||||
|
||||
// Auth represents the authentication configuration
|
||||
type Auth struct {
|
||||
Basic AuthBasic
|
||||
Token AuthToken
|
||||
OIDC AuthOIDC
|
||||
Mode AuthMode
|
||||
Basic AuthBasic
|
||||
Token AuthToken
|
||||
OIDC AuthOIDC
|
||||
Builtin AuthBuiltin
|
||||
}
|
||||
|
||||
// AuthBuiltin represents the builtin authentication configuration
|
||||
type AuthBuiltin struct {
|
||||
Admin AdminConfig
|
||||
Token TokenConfig
|
||||
}
|
||||
|
||||
// AdminConfig represents the initial admin user configuration
|
||||
type AdminConfig struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// TokenConfig represents the JWT token configuration
|
||||
type TokenConfig struct {
|
||||
Secret string
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
// AuthBasic represents the basic authentication configuration
|
||||
@ -167,6 +199,7 @@ type PathsConfig struct {
|
||||
QueueDir string
|
||||
ProcDir string
|
||||
ServiceRegistryDir string // Directory for service registry files
|
||||
UsersDir string // Directory for user data (builtin auth)
|
||||
ConfigFileUsed string // Path to the configuration file used to load settings
|
||||
}
|
||||
|
||||
@ -283,5 +316,39 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("invalid max dashboard page limit: %d", c.UI.MaxDashboardPageLimit)
|
||||
}
|
||||
|
||||
// Validate builtin auth configuration
|
||||
if err := c.validateBuiltinAuth(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateBuiltinAuth validates the builtin authentication configuration.
|
||||
func (c *Config) validateBuiltinAuth() error {
|
||||
if c.Server.Auth.Mode != AuthModeBuiltin {
|
||||
return nil
|
||||
}
|
||||
|
||||
// When builtin auth is enabled, users directory must be set
|
||||
if c.Paths.UsersDir == "" {
|
||||
return fmt.Errorf("builtin auth requires paths.usersDir to be set")
|
||||
}
|
||||
|
||||
// Admin username must be set (has default, but check anyway)
|
||||
if c.Server.Auth.Builtin.Admin.Username == "" {
|
||||
return fmt.Errorf("builtin auth requires admin username to be set")
|
||||
}
|
||||
|
||||
// Token secret must be set for JWT signing
|
||||
if c.Server.Auth.Builtin.Token.Secret == "" {
|
||||
return fmt.Errorf("builtin auth requires token secret to be set (auth.builtin.token.secret or AUTH_TOKEN_SECRET env var)")
|
||||
}
|
||||
|
||||
// Token TTL must be positive
|
||||
if c.Server.Auth.Builtin.Token.TTL <= 0 {
|
||||
return fmt.Errorf("builtin auth requires a positive token TTL")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -193,4 +193,121 @@ func TestConfig_Validate(t *testing.T) {
|
||||
err := cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("BuiltinAuth_MissingUsersDir", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := &Config{
|
||||
Server: Server{
|
||||
Port: 8080,
|
||||
Auth: Auth{
|
||||
Mode: AuthModeBuiltin,
|
||||
Builtin: AuthBuiltin{
|
||||
Admin: AdminConfig{Username: "admin"},
|
||||
Token: TokenConfig{Secret: "secret", TTL: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
Paths: PathsConfig{
|
||||
UsersDir: "",
|
||||
},
|
||||
UI: UI{
|
||||
MaxDashboardPageLimit: 1,
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "usersDir")
|
||||
})
|
||||
|
||||
t.Run("BuiltinAuth_MissingTokenSecret", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := &Config{
|
||||
Server: Server{
|
||||
Port: 8080,
|
||||
Auth: Auth{
|
||||
Mode: AuthModeBuiltin,
|
||||
Builtin: AuthBuiltin{
|
||||
Admin: AdminConfig{Username: "admin"},
|
||||
Token: TokenConfig{Secret: "", TTL: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
Paths: PathsConfig{
|
||||
UsersDir: "/tmp/users",
|
||||
},
|
||||
UI: UI{
|
||||
MaxDashboardPageLimit: 1,
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "token secret")
|
||||
})
|
||||
|
||||
t.Run("BuiltinAuth_MissingAdminUsername", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := &Config{
|
||||
Server: Server{
|
||||
Port: 8080,
|
||||
Auth: Auth{
|
||||
Mode: AuthModeBuiltin,
|
||||
Builtin: AuthBuiltin{
|
||||
Admin: AdminConfig{Username: ""},
|
||||
Token: TokenConfig{Secret: "secret", TTL: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
Paths: PathsConfig{
|
||||
UsersDir: "/tmp/users",
|
||||
},
|
||||
UI: UI{
|
||||
MaxDashboardPageLimit: 1,
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "admin username")
|
||||
})
|
||||
|
||||
t.Run("BuiltinAuth_ValidConfig", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := &Config{
|
||||
Server: Server{
|
||||
Port: 8080,
|
||||
Auth: Auth{
|
||||
Mode: AuthModeBuiltin,
|
||||
Builtin: AuthBuiltin{
|
||||
Admin: AdminConfig{Username: "admin"},
|
||||
Token: TokenConfig{Secret: "secret", TTL: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
Paths: PathsConfig{
|
||||
UsersDir: "/tmp/users",
|
||||
},
|
||||
UI: UI{
|
||||
MaxDashboardPageLimit: 1,
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("BuiltinAuth_SkippedForOtherModes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// When auth mode is not builtin, validation should pass even without builtin config
|
||||
cfg := &Config{
|
||||
Server: Server{
|
||||
Port: 8080,
|
||||
Auth: Auth{
|
||||
Mode: AuthModeNone,
|
||||
},
|
||||
},
|
||||
UI: UI{
|
||||
MaxDashboardPageLimit: 1,
|
||||
},
|
||||
}
|
||||
err := cfg.Validate()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
@ -157,9 +157,30 @@ type PeerDef struct {
|
||||
|
||||
// AuthDef holds the authentication configuration for the application.
|
||||
type AuthDef struct {
|
||||
Basic *AuthBasicDef `mapstructure:"basic"`
|
||||
Token *AuthTokenDef `mapstructure:"token"`
|
||||
OIDC *AuthOIDCDef `mapstructure:"oidc"`
|
||||
// Mode specifies the authentication mode: "none", "builtin", or "oidc"
|
||||
Mode string `mapstructure:"mode"`
|
||||
Basic *AuthBasicDef `mapstructure:"basic"`
|
||||
Token *AuthTokenDef `mapstructure:"token"`
|
||||
OIDC *AuthOIDCDef `mapstructure:"oidc"`
|
||||
Builtin *AuthBuiltinDef `mapstructure:"builtin"`
|
||||
}
|
||||
|
||||
// AuthBuiltinDef represents the builtin authentication configuration
|
||||
type AuthBuiltinDef struct {
|
||||
Admin *AdminConfigDef `mapstructure:"admin"`
|
||||
Token *TokenConfigDef `mapstructure:"token"`
|
||||
}
|
||||
|
||||
// AdminConfigDef represents the initial admin user configuration
|
||||
type AdminConfigDef struct {
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
}
|
||||
|
||||
// TokenConfigDef represents the JWT token configuration
|
||||
type TokenConfigDef struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
TTL string `mapstructure:"ttl"`
|
||||
}
|
||||
|
||||
// AuthBasicDef represents the basic authentication configuration
|
||||
@ -195,6 +216,7 @@ type PathsDef struct {
|
||||
QueueDir string `mapstructure:"queueDir"`
|
||||
ProcDir string `mapstructure:"procDir"`
|
||||
ServiceRegistryDir string `mapstructure:"serviceRegistryDir"`
|
||||
UsersDir string `mapstructure:"usersDir"`
|
||||
}
|
||||
|
||||
// UIDef holds the user interface configuration settings.
|
||||
|
||||
@ -205,6 +205,9 @@ func (l *ConfigLoader) buildConfig(def Definition) (*Config, error) {
|
||||
|
||||
// Process authentication settings.
|
||||
if def.Auth != nil {
|
||||
// Set auth mode
|
||||
cfg.Server.Auth.Mode = AuthMode(def.Auth.Mode)
|
||||
|
||||
if def.Auth.Basic != nil {
|
||||
cfg.Server.Auth.Basic.Username = def.Auth.Basic.Username
|
||||
cfg.Server.Auth.Basic.Password = def.Auth.Basic.Password
|
||||
@ -220,6 +223,41 @@ func (l *ConfigLoader) buildConfig(def Definition) (*Config, error) {
|
||||
cfg.Server.Auth.OIDC.Scopes = def.Auth.OIDC.Scopes
|
||||
cfg.Server.Auth.OIDC.Whitelist = def.Auth.OIDC.Whitelist
|
||||
}
|
||||
if def.Auth.Builtin != nil {
|
||||
if def.Auth.Builtin.Admin != nil {
|
||||
cfg.Server.Auth.Builtin.Admin.Username = def.Auth.Builtin.Admin.Username
|
||||
cfg.Server.Auth.Builtin.Admin.Password = def.Auth.Builtin.Admin.Password
|
||||
}
|
||||
if def.Auth.Builtin.Token != nil {
|
||||
cfg.Server.Auth.Builtin.Token.Secret = def.Auth.Builtin.Token.Secret
|
||||
if def.Auth.Builtin.Token.TTL != "" {
|
||||
if duration, err := time.ParseDuration(def.Auth.Builtin.Token.TTL); err == nil {
|
||||
cfg.Server.Auth.Builtin.Token.TTL = duration
|
||||
} else {
|
||||
l.warnings = append(l.warnings, fmt.Sprintf("Invalid auth.builtin.token.ttl value: %s", def.Auth.Builtin.Token.TTL))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set default token TTL if not specified
|
||||
if cfg.Server.Auth.Builtin.Token.TTL <= 0 {
|
||||
cfg.Server.Auth.Builtin.Token.TTL = 24 * time.Hour
|
||||
}
|
||||
// Set default admin username if not specified
|
||||
if cfg.Server.Auth.Builtin.Admin.Username == "" {
|
||||
cfg.Server.Auth.Builtin.Admin.Username = "admin"
|
||||
}
|
||||
|
||||
// Auto-detect auth mode if not explicitly set
|
||||
// If OIDC is configured (clientId, clientSecret, and issuer are set), default to OIDC mode
|
||||
if cfg.Server.Auth.Mode == "" {
|
||||
oidc := cfg.Server.Auth.OIDC
|
||||
if oidc.ClientId != "" && oidc.ClientSecret != "" && oidc.Issuer != "" {
|
||||
cfg.Server.Auth.Mode = AuthModeOIDC
|
||||
l.warnings = append(l.warnings, fmt.Sprintf("Auth mode auto-detected as 'oidc' based on OIDC configuration (issuer: %s)", oidc.Issuer))
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize the BasePath value for proper URL construction.
|
||||
@ -238,6 +276,7 @@ func (l *ConfigLoader) buildConfig(def Definition) (*Config, error) {
|
||||
cfg.Paths.QueueDir = fileutil.ResolvePathOrBlank(def.Paths.QueueDir)
|
||||
cfg.Paths.ProcDir = fileutil.ResolvePathOrBlank(def.Paths.ProcDir)
|
||||
cfg.Paths.ServiceRegistryDir = fileutil.ResolvePathOrBlank(def.Paths.ServiceRegistryDir)
|
||||
cfg.Paths.UsersDir = fileutil.ResolvePathOrBlank(def.Paths.UsersDir)
|
||||
}
|
||||
|
||||
// Set UI configuration if provided.
|
||||
@ -400,6 +439,9 @@ func (l *ConfigLoader) buildConfig(def Definition) (*Config, error) {
|
||||
if cfg.Paths.ServiceRegistryDir == "" {
|
||||
cfg.Paths.ServiceRegistryDir = filepath.Join(cfg.Paths.DataDir, "service-registry")
|
||||
}
|
||||
if cfg.Paths.UsersDir == "" {
|
||||
cfg.Paths.UsersDir = filepath.Join(cfg.Paths.DataDir, "users")
|
||||
}
|
||||
|
||||
// Ensure the executable path is set.
|
||||
if cfg.Paths.Executable == "" {
|
||||
@ -597,7 +639,7 @@ func setViperDefaultValues(paths Paths) {
|
||||
// bindEnvironmentVariables binds configuration keys to the environment variable names used by Viper.
|
||||
// It registers current and legacy environment names for server, global, scheduler, UI, authentication
|
||||
// (including OIDC and legacy keys), TLS, file paths (with path normalization where appropriate),
|
||||
// coordinator, worker, peer, queues, and monitoring settings.
|
||||
// (e.g., path normalization) so environment values override config settings.
|
||||
func bindEnvironmentVariables() {
|
||||
// Server configurations
|
||||
bindEnv("logFormat", "LOG_FORMAT")
|
||||
@ -651,6 +693,13 @@ func bindEnvironmentVariables() {
|
||||
bindEnv("auth.basic.password", "BASICAUTH_PASSWORD")
|
||||
bindEnv("auth.token.value", "AUTHTOKEN")
|
||||
|
||||
// Authentication configurations (builtin)
|
||||
bindEnv("auth.mode", "AUTH_MODE")
|
||||
bindEnv("auth.builtin.admin.username", "AUTH_ADMIN_USERNAME")
|
||||
bindEnv("auth.builtin.admin.password", "AUTH_ADMIN_PASSWORD")
|
||||
bindEnv("auth.builtin.token.secret", "AUTH_TOKEN_SECRET")
|
||||
bindEnv("auth.builtin.token.ttl", "AUTH_TOKEN_TTL")
|
||||
|
||||
// TLS configurations
|
||||
bindEnv("tls.certFile", "CERT_FILE")
|
||||
bindEnv("tls.keyFile", "KEY_FILE")
|
||||
@ -668,6 +717,7 @@ func bindEnvironmentVariables() {
|
||||
bindEnv("paths.procDir", "PROC_DIR", asPath())
|
||||
bindEnv("paths.queueDir", "QUEUE_DIR", asPath())
|
||||
bindEnv("paths.serviceRegistryDir", "SERVICE_REGISTRY_DIR", asPath())
|
||||
bindEnv("paths.usersDir", "USERS_DIR", asPath())
|
||||
|
||||
// UI customization
|
||||
bindEnv("latestStatusToday", "LATEST_STATUS_TODAY")
|
||||
|
||||
@ -151,6 +151,7 @@ func TestLoad_Env(t *testing.T) {
|
||||
APIBasePath: "/test/api",
|
||||
Headless: true,
|
||||
Auth: Auth{
|
||||
Mode: AuthModeOIDC, // Auto-detected from OIDC config
|
||||
Basic: AuthBasic{Username: "testuser", Password: "testpass"},
|
||||
Token: AuthToken{Value: "test-token-123"},
|
||||
OIDC: AuthOIDC{
|
||||
@ -159,6 +160,10 @@ func TestLoad_Env(t *testing.T) {
|
||||
Issuer: "https://auth.example.com",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
},
|
||||
Builtin: AuthBuiltin{
|
||||
Admin: AdminConfig{Username: "admin"},
|
||||
Token: TokenConfig{TTL: 24 * time.Hour},
|
||||
},
|
||||
},
|
||||
TLS: &TLSConfig{
|
||||
CertFile: filepath.Join(testPaths, "cert.pem"),
|
||||
@ -180,6 +185,7 @@ func TestLoad_Env(t *testing.T) {
|
||||
ProcDir: filepath.Join(testPaths, "proc"),
|
||||
QueueDir: filepath.Join(testPaths, "queue"),
|
||||
ServiceRegistryDir: filepath.Join(testPaths, "service-registry"),
|
||||
UsersDir: filepath.Join(testPaths, "data", "users"), // Derived from DataDir
|
||||
},
|
||||
UI: UI{
|
||||
LogEncodingCharset: "iso-8859-1",
|
||||
@ -211,6 +217,7 @@ func TestLoad_Env(t *testing.T) {
|
||||
Retention: 24 * time.Hour,
|
||||
Interval: 5 * time.Second,
|
||||
},
|
||||
Warnings: []string{"Auth mode auto-detected as 'oidc' based on OIDC configuration (issuer: https://auth.example.com)"},
|
||||
}
|
||||
|
||||
assert.Equal(t, expected, cfg)
|
||||
@ -356,6 +363,7 @@ scheduler:
|
||||
Headless: true,
|
||||
LatestStatusToday: true,
|
||||
Auth: Auth{
|
||||
Mode: AuthModeOIDC, // Auto-detected from OIDC config
|
||||
Basic: AuthBasic{Username: "admin", Password: "secret"},
|
||||
Token: AuthToken{Value: "api-token"},
|
||||
OIDC: AuthOIDC{
|
||||
@ -366,6 +374,10 @@ scheduler:
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
Whitelist: []string{"user@example.com"},
|
||||
},
|
||||
Builtin: AuthBuiltin{
|
||||
Admin: AdminConfig{Username: "admin"},
|
||||
Token: TokenConfig{TTL: 24 * time.Hour},
|
||||
},
|
||||
},
|
||||
TLS: &TLSConfig{
|
||||
CertFile: "/path/to/cert.pem",
|
||||
@ -405,6 +417,7 @@ scheduler:
|
||||
ProcDir: "/var/dagu/data/proc",
|
||||
QueueDir: "/var/dagu/data/queue",
|
||||
ServiceRegistryDir: "/var/dagu/data/service-registry",
|
||||
UsersDir: "/var/dagu/data/users",
|
||||
},
|
||||
UI: UI{
|
||||
LogEncodingCharset: "iso-8859-1",
|
||||
@ -445,6 +458,7 @@ scheduler:
|
||||
Retention: 24 * time.Hour,
|
||||
Interval: 5 * time.Second,
|
||||
},
|
||||
Warnings: []string{"Auth mode auto-detected as 'oidc' based on OIDC configuration (issuer: https://accounts.example.com)"},
|
||||
}
|
||||
|
||||
assert.Equal(t, expected, cfg)
|
||||
@ -499,6 +513,7 @@ paths:
|
||||
assert.Equal(t, "/custom/data/proc", cfg.Paths.ProcDir)
|
||||
assert.Equal(t, "/custom/data/queue", cfg.Paths.QueueDir)
|
||||
assert.Equal(t, "/custom/data/service-registry", cfg.Paths.ServiceRegistryDir)
|
||||
assert.Equal(t, "/custom/data/users", cfg.Paths.UsersDir)
|
||||
}
|
||||
|
||||
func TestLoad_EdgeCases_Errors(t *testing.T) {
|
||||
@ -816,3 +831,131 @@ monitoring:
|
||||
assert.Equal(t, 5*time.Second, cfg.Monitoring.Interval)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoad_AuthMode(t *testing.T) {
|
||||
t.Run("AuthModeNone", func(t *testing.T) {
|
||||
cfg := loadFromYAML(t, `
|
||||
auth:
|
||||
mode: "none"
|
||||
`)
|
||||
assert.Equal(t, AuthModeNone, cfg.Server.Auth.Mode)
|
||||
})
|
||||
|
||||
t.Run("AuthModeBuiltin", func(t *testing.T) {
|
||||
cfg := loadFromYAML(t, `
|
||||
auth:
|
||||
mode: "builtin"
|
||||
builtin:
|
||||
admin:
|
||||
username: "admin"
|
||||
password: "secretpass123"
|
||||
token:
|
||||
secret: "my-jwt-secret-key"
|
||||
ttl: "12h"
|
||||
`)
|
||||
assert.Equal(t, AuthModeBuiltin, cfg.Server.Auth.Mode)
|
||||
assert.Equal(t, "admin", cfg.Server.Auth.Builtin.Admin.Username)
|
||||
assert.Equal(t, "secretpass123", cfg.Server.Auth.Builtin.Admin.Password)
|
||||
assert.Equal(t, "my-jwt-secret-key", cfg.Server.Auth.Builtin.Token.Secret)
|
||||
assert.Equal(t, 12*time.Hour, cfg.Server.Auth.Builtin.Token.TTL)
|
||||
})
|
||||
|
||||
t.Run("AuthModeOIDC", func(t *testing.T) {
|
||||
cfg := loadFromYAML(t, `
|
||||
auth:
|
||||
mode: "oidc"
|
||||
oidc:
|
||||
clientId: "my-client-id"
|
||||
clientSecret: "my-client-secret"
|
||||
issuer: "https://auth.example.com"
|
||||
scopes:
|
||||
- "openid"
|
||||
- "profile"
|
||||
`)
|
||||
assert.Equal(t, AuthModeOIDC, cfg.Server.Auth.Mode)
|
||||
assert.Equal(t, "my-client-id", cfg.Server.Auth.OIDC.ClientId)
|
||||
assert.Equal(t, "my-client-secret", cfg.Server.Auth.OIDC.ClientSecret)
|
||||
assert.Equal(t, "https://auth.example.com", cfg.Server.Auth.OIDC.Issuer)
|
||||
assert.Equal(t, []string{"openid", "profile"}, cfg.Server.Auth.OIDC.Scopes)
|
||||
})
|
||||
|
||||
t.Run("AuthModeFromEnv", func(t *testing.T) {
|
||||
cfg := loadWithEnv(t, "# empty", map[string]string{
|
||||
"DAGU_AUTH_MODE": "builtin",
|
||||
"DAGU_AUTH_TOKEN_SECRET": "test-secret",
|
||||
"DAGU_PATHS_USERS_DIR": t.TempDir(),
|
||||
})
|
||||
assert.Equal(t, AuthModeBuiltin, cfg.Server.Auth.Mode)
|
||||
})
|
||||
|
||||
t.Run("AuthModeDefaultEmpty", func(t *testing.T) {
|
||||
cfg := loadFromYAML(t, "# empty")
|
||||
assert.Equal(t, AuthMode(""), cfg.Server.Auth.Mode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoad_AuthBuiltin(t *testing.T) {
|
||||
t.Run("FromYAML", func(t *testing.T) {
|
||||
cfg := loadFromYAML(t, `
|
||||
auth:
|
||||
mode: "builtin"
|
||||
builtin:
|
||||
admin:
|
||||
username: "superadmin"
|
||||
password: "supersecret123"
|
||||
token:
|
||||
secret: "jwt-signing-secret"
|
||||
ttl: "24h"
|
||||
`)
|
||||
assert.Equal(t, AuthModeBuiltin, cfg.Server.Auth.Mode)
|
||||
assert.Equal(t, "superadmin", cfg.Server.Auth.Builtin.Admin.Username)
|
||||
assert.Equal(t, "supersecret123", cfg.Server.Auth.Builtin.Admin.Password)
|
||||
assert.Equal(t, "jwt-signing-secret", cfg.Server.Auth.Builtin.Token.Secret)
|
||||
assert.Equal(t, 24*time.Hour, cfg.Server.Auth.Builtin.Token.TTL)
|
||||
})
|
||||
|
||||
t.Run("FromEnv", func(t *testing.T) {
|
||||
cfg := loadWithEnv(t, "# empty", map[string]string{
|
||||
"DAGU_AUTH_MODE": "builtin",
|
||||
"DAGU_AUTH_ADMIN_USERNAME": "envadmin",
|
||||
"DAGU_AUTH_ADMIN_PASSWORD": "envpassword123",
|
||||
"DAGU_AUTH_TOKEN_SECRET": "env-jwt-secret",
|
||||
"DAGU_AUTH_TOKEN_TTL": "48h",
|
||||
})
|
||||
assert.Equal(t, AuthModeBuiltin, cfg.Server.Auth.Mode)
|
||||
assert.Equal(t, "envadmin", cfg.Server.Auth.Builtin.Admin.Username)
|
||||
assert.Equal(t, "envpassword123", cfg.Server.Auth.Builtin.Admin.Password)
|
||||
assert.Equal(t, "env-jwt-secret", cfg.Server.Auth.Builtin.Token.Secret)
|
||||
assert.Equal(t, 48*time.Hour, cfg.Server.Auth.Builtin.Token.TTL)
|
||||
})
|
||||
|
||||
t.Run("EmptyPasswordAllowed", func(t *testing.T) {
|
||||
cfg := loadFromYAML(t, `
|
||||
auth:
|
||||
mode: "builtin"
|
||||
builtin:
|
||||
admin:
|
||||
username: "admin"
|
||||
password: ""
|
||||
token:
|
||||
secret: "secret"
|
||||
ttl: "1h"
|
||||
`)
|
||||
assert.Equal(t, "admin", cfg.Server.Auth.Builtin.Admin.Username)
|
||||
assert.Equal(t, "", cfg.Server.Auth.Builtin.Admin.Password)
|
||||
})
|
||||
|
||||
t.Run("DefaultTTL", func(t *testing.T) {
|
||||
cfg := loadFromYAML(t, `
|
||||
auth:
|
||||
mode: "builtin"
|
||||
builtin:
|
||||
admin:
|
||||
username: "admin"
|
||||
token:
|
||||
secret: "secret"
|
||||
`)
|
||||
// TTL defaults to 24 hours when not specified
|
||||
assert.Equal(t, 24*time.Hour, cfg.Server.Auth.Builtin.Token.TTL)
|
||||
})
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ func TestQueueShellConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
// Start the frontend server
|
||||
server := frontend.NewServer(
|
||||
server, err := frontend.NewServer(
|
||||
th.Config,
|
||||
th.DAGStore,
|
||||
th.DAGRunStore,
|
||||
@ -49,6 +49,7 @@ func TestQueueShellConfig(t *testing.T) {
|
||||
nil, // no metrics registry
|
||||
nil, // no resource service
|
||||
)
|
||||
require.NoError(t, err, "failed to create server")
|
||||
|
||||
go func() {
|
||||
_ = server.Serve(th.Context)
|
||||
|
||||
360
internal/persistence/fileuser/store.go
Normal file
360
internal/persistence/fileuser/store.go
Normal file
@ -0,0 +1,360 @@
|
||||
// Copyright (C) 2024 Yota Hamada
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package fileuser provides a file-based implementation of the UserStore interface.
|
||||
package fileuser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/dagu-org/dagu/internal/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
// userFileExtension is the file extension for user files.
|
||||
userFileExtension = ".json"
|
||||
// userDirPermissions is the permission mode for the users directory.
|
||||
userDirPermissions = 0750
|
||||
// userFilePermissions is the permission mode for user files.
|
||||
userFilePermissions = 0600
|
||||
)
|
||||
|
||||
// Store implements auth.UserStore using the local filesystem.
|
||||
// Users are stored as individual JSON files in the configured directory.
|
||||
// Thread-safe through internal locking.
|
||||
type Store struct {
|
||||
baseDir string
|
||||
|
||||
// mu protects the index maps
|
||||
mu sync.RWMutex
|
||||
// byID maps user ID to file path
|
||||
byID map[string]string
|
||||
// byUsername maps username to user ID
|
||||
byUsername map[string]string
|
||||
}
|
||||
|
||||
// Option is a functional option for configuring the Store.
|
||||
type Option func(*Store)
|
||||
|
||||
// New creates a new file-based user store.
|
||||
// New creates a file-backed Store that persists users as per-user JSON files in baseDir.
|
||||
// The baseDir must be non-empty; provided Option functions are applied to the store.
|
||||
// If baseDir does not exist it is created with directory permissions 0750, and an initial
|
||||
// in-memory index is built from existing user files. Returns an error on invalid input,
|
||||
// failure to create the directory, or failure to build the initial index.
|
||||
func New(baseDir string, opts ...Option) (*Store, error) {
|
||||
if baseDir == "" {
|
||||
return nil, errors.New("fileuser: baseDir cannot be empty")
|
||||
}
|
||||
|
||||
store := &Store{
|
||||
baseDir: baseDir,
|
||||
byID: make(map[string]string),
|
||||
byUsername: make(map[string]string),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(store)
|
||||
}
|
||||
|
||||
// Create base directory if it doesn't exist
|
||||
if err := os.MkdirAll(baseDir, userDirPermissions); err != nil {
|
||||
return nil, fmt.Errorf("fileuser: failed to create directory %s: %w", baseDir, err)
|
||||
}
|
||||
|
||||
// Build initial index
|
||||
if err := store.rebuildIndex(); err != nil {
|
||||
return nil, fmt.Errorf("fileuser: failed to build index: %w", err)
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// rebuildIndex scans the directory and rebuilds the in-memory index.
|
||||
func (s *Store) rebuildIndex() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Clear existing index
|
||||
s.byID = make(map[string]string)
|
||||
s.byUsername = make(map[string]string)
|
||||
|
||||
// Scan directory for user files
|
||||
entries, err := os.ReadDir(s.baseDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read directory %s: %w", s.baseDir, err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != userFileExtension {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(s.baseDir, entry.Name())
|
||||
user, err := s.loadUserFromFile(filePath)
|
||||
if err != nil {
|
||||
// Log warning but continue - don't fail entire index for one bad file
|
||||
slog.Warn("Failed to load user file during index rebuild",
|
||||
slog.String("file", filePath),
|
||||
slog.String("error", err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
s.byID[user.ID] = filePath
|
||||
s.byUsername[user.Username] = user.ID
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadUserFromFile reads and parses a user from a JSON file.
|
||||
func (s *Store) loadUserFromFile(filePath string) (*auth.User, error) {
|
||||
data, err := os.ReadFile(filePath) //nolint:gosec // filePath is constructed internally from baseDir + userID
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
var stored auth.UserForStorage
|
||||
if err := json.Unmarshal(data, &stored); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse user file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return stored.ToUser(), nil
|
||||
}
|
||||
|
||||
// userFilePath returns the file path for a user ID.
|
||||
func (s *Store) userFilePath(userID string) string {
|
||||
return filepath.Join(s.baseDir, userID+userFileExtension)
|
||||
}
|
||||
|
||||
// Create stores a new user.
|
||||
func (s *Store) Create(_ context.Context, user *auth.User) error {
|
||||
if user == nil {
|
||||
return errors.New("fileuser: user cannot be nil")
|
||||
}
|
||||
if user.ID == "" {
|
||||
return auth.ErrInvalidUserID
|
||||
}
|
||||
if user.Username == "" {
|
||||
return auth.ErrInvalidUsername
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check if username already exists
|
||||
if _, exists := s.byUsername[user.Username]; exists {
|
||||
return auth.ErrUserAlreadyExists
|
||||
}
|
||||
|
||||
// Check if ID already exists (shouldn't happen with UUIDs, but be safe)
|
||||
if _, exists := s.byID[user.ID]; exists {
|
||||
return auth.ErrUserAlreadyExists
|
||||
}
|
||||
|
||||
// Write user to file
|
||||
filePath := s.userFilePath(user.ID)
|
||||
if err := s.writeUserToFile(filePath, user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update index
|
||||
s.byID[user.ID] = filePath
|
||||
s.byUsername[user.Username] = user.ID
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeUserToFile writes a user to a JSON file atomically.
|
||||
func (s *Store) writeUserToFile(filePath string, user *auth.User) error {
|
||||
data, err := json.MarshalIndent(user.ToStorage(), "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("fileuser: failed to marshal user: %w", err)
|
||||
}
|
||||
|
||||
// Write to temp file first, then rename for atomicity
|
||||
tempPath := filePath + ".tmp"
|
||||
if err := os.WriteFile(tempPath, data, userFilePermissions); err != nil {
|
||||
return fmt.Errorf("fileuser: failed to write file %s: %w", tempPath, err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tempPath, filePath); err != nil {
|
||||
// Clean up temp file on failure
|
||||
_ = os.Remove(tempPath)
|
||||
return fmt.Errorf("fileuser: failed to rename file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID retrieves a user by their unique ID.
|
||||
func (s *Store) GetByID(_ context.Context, id string) (*auth.User, error) {
|
||||
if id == "" {
|
||||
return nil, auth.ErrInvalidUserID
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
filePath, exists := s.byID[id]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, auth.ErrUserNotFound
|
||||
}
|
||||
|
||||
user, err := s.loadUserFromFile(filePath)
|
||||
if err != nil {
|
||||
// File might have been deleted externally
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, auth.ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("fileuser: failed to load user %s: %w", id, err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByUsername retrieves a user by their username.
|
||||
func (s *Store) GetByUsername(ctx context.Context, username string) (*auth.User, error) {
|
||||
if username == "" {
|
||||
return nil, auth.ErrInvalidUsername
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
userID, exists := s.byUsername[username]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, auth.ErrUserNotFound
|
||||
}
|
||||
|
||||
return s.GetByID(ctx, userID)
|
||||
}
|
||||
|
||||
// List returns all users in the store.
|
||||
func (s *Store) List(ctx context.Context) ([]*auth.User, error) {
|
||||
s.mu.RLock()
|
||||
ids := make([]string, 0, len(s.byID))
|
||||
for id := range s.byID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
users := make([]*auth.User, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
user, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
// Skip users that can't be loaded
|
||||
if errors.Is(err, auth.ErrUserNotFound) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// Update modifies an existing user.
|
||||
func (s *Store) Update(_ context.Context, user *auth.User) error {
|
||||
if user == nil {
|
||||
return errors.New("fileuser: user cannot be nil")
|
||||
}
|
||||
if user.ID == "" {
|
||||
return auth.ErrInvalidUserID
|
||||
}
|
||||
if user.Username == "" {
|
||||
return auth.ErrInvalidUsername
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
filePath, exists := s.byID[user.ID]
|
||||
if !exists {
|
||||
return auth.ErrUserNotFound
|
||||
}
|
||||
|
||||
// Load existing user to check for username change
|
||||
existingUser, err := s.loadUserFromFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fileuser: failed to load existing user: %w", err)
|
||||
}
|
||||
|
||||
// If username changed, check for conflicts and update index
|
||||
if existingUser.Username != user.Username {
|
||||
if existingID, taken := s.byUsername[user.Username]; taken && existingID != user.ID {
|
||||
return auth.ErrUserAlreadyExists
|
||||
}
|
||||
delete(s.byUsername, existingUser.Username)
|
||||
s.byUsername[user.Username] = user.ID
|
||||
}
|
||||
|
||||
// Write updated user
|
||||
if err := s.writeUserToFile(filePath, user); err != nil {
|
||||
// Rollback index change on failure
|
||||
if existingUser.Username != user.Username {
|
||||
delete(s.byUsername, user.Username)
|
||||
s.byUsername[existingUser.Username] = user.ID
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a user by their ID.
|
||||
func (s *Store) Delete(_ context.Context, id string) error {
|
||||
if id == "" {
|
||||
return auth.ErrInvalidUserID
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
filePath, exists := s.byID[id]
|
||||
if !exists {
|
||||
return auth.ErrUserNotFound
|
||||
}
|
||||
|
||||
// Load user to get username for index cleanup
|
||||
user, err := s.loadUserFromFile(filePath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("fileuser: failed to load user for deletion: %w", err)
|
||||
}
|
||||
|
||||
// Remove file
|
||||
if err := os.Remove(filePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("fileuser: failed to delete user file: %w", err)
|
||||
}
|
||||
|
||||
// Update index
|
||||
delete(s.byID, id)
|
||||
if user != nil {
|
||||
delete(s.byUsername, user.Username)
|
||||
} else {
|
||||
// File was already gone; remove any username entry that still points to this ID.
|
||||
for username, userID := range s.byUsername {
|
||||
if userID == id {
|
||||
delete(s.byUsername, username)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count returns the total number of users.
|
||||
func (s *Store) Count(_ context.Context) (int64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return int64(len(s.byID)), nil
|
||||
}
|
||||
240
internal/persistence/fileuser/store_test.go
Normal file
240
internal/persistence/fileuser/store_test.go
Normal file
@ -0,0 +1,240 @@
|
||||
// Copyright (C) 2024 Yota Hamada
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package fileuser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/dagu-org/dagu/internal/auth"
|
||||
)
|
||||
|
||||
func TestStore_CRUD(t *testing.T) {
|
||||
// Create temp directory
|
||||
tmpDir, err := os.MkdirTemp("", "fileuser-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
// Create store
|
||||
store, err := New(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test Create
|
||||
user := auth.NewUser("testuser", "hashedpassword", auth.RoleManager)
|
||||
if err := store.Create(ctx, user); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
// Test GetByID
|
||||
retrieved, err := store.GetByID(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID() error = %v", err)
|
||||
}
|
||||
if retrieved.Username != user.Username {
|
||||
t.Errorf("GetByID() username = %v, want %v", retrieved.Username, user.Username)
|
||||
}
|
||||
if retrieved.Role != user.Role {
|
||||
t.Errorf("GetByID() role = %v, want %v", retrieved.Role, user.Role)
|
||||
}
|
||||
|
||||
// Test GetByUsername
|
||||
retrieved, err = store.GetByUsername(ctx, user.Username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByUsername() error = %v", err)
|
||||
}
|
||||
if retrieved.ID != user.ID {
|
||||
t.Errorf("GetByUsername() ID = %v, want %v", retrieved.ID, user.ID)
|
||||
}
|
||||
|
||||
// Test List
|
||||
users, err := store.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Errorf("List() returned %d users, want 1", len(users))
|
||||
}
|
||||
|
||||
// Test Count
|
||||
count, err := store.Count(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Count() error = %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("Count() = %d, want 1", count)
|
||||
}
|
||||
|
||||
// Test Update
|
||||
user.Role = auth.RoleAdmin
|
||||
if err := store.Update(ctx, user); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
retrieved, err = store.GetByID(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID() after Update error = %v", err)
|
||||
}
|
||||
if retrieved.Role != auth.RoleAdmin {
|
||||
t.Errorf("Update() role = %v, want %v", retrieved.Role, auth.RoleAdmin)
|
||||
}
|
||||
|
||||
// Test Delete
|
||||
if err := store.Delete(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
_, err = store.GetByID(ctx, user.ID)
|
||||
if err != auth.ErrUserNotFound {
|
||||
t.Errorf("GetByID() after delete error = %v, want %v", err, auth.ErrUserNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_DuplicateUsername(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "fileuser-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
store, err := New(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create first user
|
||||
user1 := auth.NewUser("testuser", "hash1", auth.RoleViewer)
|
||||
if err := store.Create(ctx, user1); err != nil {
|
||||
t.Fatalf("Create() first user error = %v", err)
|
||||
}
|
||||
|
||||
// Try to create second user with same username
|
||||
user2 := auth.NewUser("testuser", "hash2", auth.RoleManager)
|
||||
err = store.Create(ctx, user2)
|
||||
if err != auth.ErrUserAlreadyExists {
|
||||
t.Errorf("Create() duplicate username error = %v, want %v", err, auth.ErrUserAlreadyExists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_NotFound(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "fileuser-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
store, err := New(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test GetByID not found
|
||||
_, err = store.GetByID(ctx, "nonexistent-id")
|
||||
if err != auth.ErrUserNotFound {
|
||||
t.Errorf("GetByID() error = %v, want %v", err, auth.ErrUserNotFound)
|
||||
}
|
||||
|
||||
// Test GetByUsername not found
|
||||
_, err = store.GetByUsername(ctx, "nonexistent-user")
|
||||
if err != auth.ErrUserNotFound {
|
||||
t.Errorf("GetByUsername() error = %v, want %v", err, auth.ErrUserNotFound)
|
||||
}
|
||||
|
||||
// Test Delete not found
|
||||
err = store.Delete(ctx, "nonexistent-id")
|
||||
if err != auth.ErrUserNotFound {
|
||||
t.Errorf("Delete() error = %v, want %v", err, auth.ErrUserNotFound)
|
||||
}
|
||||
|
||||
// Test Update not found
|
||||
user := auth.NewUser("test", "hash", auth.RoleViewer)
|
||||
err = store.Update(ctx, user)
|
||||
if err != auth.ErrUserNotFound {
|
||||
t.Errorf("Update() error = %v, want %v", err, auth.ErrUserNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_RebuildIndex(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "fileuser-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
// Create store and add user
|
||||
store1, err := New(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
user := auth.NewUser("testuser", "hash", auth.RoleAdmin)
|
||||
if err := store1.Create(ctx, user); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
// Create new store instance (simulates restart)
|
||||
store2, err := New(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create second store: %v", err)
|
||||
}
|
||||
|
||||
// Verify user is found after index rebuild
|
||||
retrieved, err := store2.GetByUsername(ctx, "testuser")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByUsername() after rebuild error = %v", err)
|
||||
}
|
||||
if retrieved.ID != user.ID {
|
||||
t.Errorf("GetByUsername() after rebuild ID = %v, want %v", retrieved.ID, user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_EmptyBaseDir(t *testing.T) {
|
||||
_, err := New("")
|
||||
if err == nil {
|
||||
t.Error("New() with empty baseDir should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_FileExists(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "fileuser-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
store, err := New(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
user := auth.NewUser("testuser", "hash", auth.RoleViewer)
|
||||
if err := store.Create(ctx, user); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
filePath := filepath.Join(tmpDir, user.ID+".json")
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
t.Error("User file should exist after Create()")
|
||||
}
|
||||
|
||||
// Verify file is deleted after Delete
|
||||
if err := store.Delete(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
|
||||
t.Error("User file should not exist after Delete()")
|
||||
}
|
||||
}
|
||||
393
internal/service/auth/service.go
Normal file
393
internal/service/auth/service.go
Normal file
@ -0,0 +1,393 @@
|
||||
// Copyright (C) 2024 Yota Hamada
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package auth provides authentication and user management services.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dagu-org/dagu/internal/auth"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Service errors.
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
ErrInvalidToken = errors.New("invalid or expired token")
|
||||
ErrTokenExpired = errors.New("token has expired")
|
||||
ErrMissingSecret = errors.New("token secret is not configured")
|
||||
ErrPasswordMismatch = errors.New("current password is incorrect")
|
||||
ErrWeakPassword = errors.New("password does not meet requirements")
|
||||
ErrCannotDeleteSelf = errors.New("cannot delete your own account")
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultBcryptCost is the default cost for bcrypt hashing.
|
||||
defaultBcryptCost = 12
|
||||
// minPasswordLength is the minimum required password length.
|
||||
minPasswordLength = 8
|
||||
// defaultTokenTTL is the default token time-to-live.
|
||||
defaultTokenTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Config holds the configuration for the auth service.
|
||||
type Config struct {
|
||||
// TokenSecret is the secret key for signing JWT tokens.
|
||||
TokenSecret string
|
||||
// TokenTTL is the token time-to-live.
|
||||
TokenTTL time.Duration
|
||||
// BcryptCost is the cost factor for bcrypt hashing.
|
||||
BcryptCost int
|
||||
}
|
||||
|
||||
// Claims represents the JWT claims.
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
UserID string `json:"uid"`
|
||||
Username string `json:"username"`
|
||||
Role auth.Role `json:"role"`
|
||||
}
|
||||
|
||||
// Service provides authentication and user management functionality.
|
||||
type Service struct {
|
||||
store auth.UserStore
|
||||
config Config
|
||||
}
|
||||
|
||||
// New creates a new auth service using the provided user store and configuration.
|
||||
// If TokenTTL or BcryptCost are not set (<= 0) they are replaced with package defaults.
|
||||
func New(store auth.UserStore, config Config) *Service {
|
||||
if config.TokenTTL <= 0 {
|
||||
config.TokenTTL = defaultTokenTTL
|
||||
}
|
||||
if config.BcryptCost <= 0 {
|
||||
config.BcryptCost = defaultBcryptCost
|
||||
}
|
||||
return &Service{
|
||||
store: store,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// dummyHash is a valid bcrypt hash used for timing attack prevention.
|
||||
// When a user is not found, we still perform a bcrypt comparison against this
|
||||
// hash to ensure consistent response times regardless of user existence.
|
||||
var dummyHash = []byte("$2a$12$K8gHXqrFdFvMwJBG0VlJGuAGz3FwBmTm8xnNQblN2tCxrQgPLmwHa")
|
||||
|
||||
// Authenticate verifies credentials and returns the user if valid.
|
||||
func (s *Service) Authenticate(ctx context.Context, username, password string) (*auth.User, error) {
|
||||
user, err := s.store.GetByUsername(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUserNotFound) || errors.Is(err, auth.ErrInvalidUsername) {
|
||||
// Use constant-time comparison to prevent timing attacks.
|
||||
// Compare against a valid bcrypt hash to ensure similar timing.
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// TokenResult contains the generated token and its expiry time.
|
||||
type TokenResult struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// GenerateToken creates a JWT token for the given user.
|
||||
// Returns the token string and its expiry time.
|
||||
func (s *Service) GenerateToken(user *auth.User) (*TokenResult, error) {
|
||||
if s.config.TokenSecret == "" {
|
||||
return nil, ErrMissingSecret
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(s.config.TokenTTL)
|
||||
claims := &Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: user.ID,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
},
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signedToken, err := token.SignedString([]byte(s.config.TokenSecret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenResult{
|
||||
Token: signedToken,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateToken validates a JWT token and returns the claims.
|
||||
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
|
||||
if s.config.TokenSecret == "" {
|
||||
return nil, ErrMissingSecret
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.config.TokenSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// GetUserFromToken validates a token and returns the associated user.
|
||||
func (s *Service) GetUserFromToken(ctx context.Context, tokenString string) (*auth.User, error) {
|
||||
claims, err := s.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := s.store.GetByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
// If user was deleted after token was issued, treat as invalid token
|
||||
if errors.Is(err, auth.ErrUserNotFound) || errors.Is(err, auth.ErrInvalidUserID) {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user from token: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// CreateUserInput contains the input for creating a user.
|
||||
type CreateUserInput struct {
|
||||
Username string
|
||||
Password string
|
||||
Role auth.Role
|
||||
}
|
||||
|
||||
// CreateUser creates a new user.
|
||||
func (s *Service) CreateUser(ctx context.Context, input CreateUserInput) (*auth.User, error) {
|
||||
if err := s.validatePassword(input.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !input.Role.Valid() {
|
||||
return nil, fmt.Errorf("invalid role: %s", input.Role)
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), s.config.BcryptCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
user := auth.NewUser(input.Username, string(passwordHash), input.Role)
|
||||
if err := s.store.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetUser retrieves a user by ID.
|
||||
func (s *Service) GetUser(ctx context.Context, id string) (*auth.User, error) {
|
||||
return s.store.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// ListUsers returns all users.
|
||||
func (s *Service) ListUsers(ctx context.Context) ([]*auth.User, error) {
|
||||
return s.store.List(ctx)
|
||||
}
|
||||
|
||||
// UpdateUserInput contains the input for updating a user.
|
||||
// Note: Password field is supported by the service for direct usage,
|
||||
// but the API handler intentionally omits it - password changes should
|
||||
// go through ChangePassword (user self-service) or ResetPassword (admin).
|
||||
type UpdateUserInput struct {
|
||||
Username *string
|
||||
Role *auth.Role
|
||||
Password *string
|
||||
}
|
||||
|
||||
// UpdateUser updates an existing user.
|
||||
func (s *Service) UpdateUser(ctx context.Context, id string, input UpdateUserInput) (*auth.User, error) {
|
||||
user, err := s.store.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if input.Username != nil && *input.Username != "" {
|
||||
user.Username = *input.Username
|
||||
}
|
||||
|
||||
if input.Role != nil {
|
||||
if !input.Role.Valid() {
|
||||
return nil, fmt.Errorf("invalid role: %s", *input.Role)
|
||||
}
|
||||
user.Role = *input.Role
|
||||
}
|
||||
|
||||
if input.Password != nil && *input.Password != "" {
|
||||
if err := s.validatePassword(*input.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*input.Password), s.config.BcryptCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
user.PasswordHash = string(passwordHash)
|
||||
}
|
||||
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := s.store.Update(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user by ID.
|
||||
// The currentUserID prevents users from deleting themselves.
|
||||
func (s *Service) DeleteUser(ctx context.Context, id string, currentUserID string) error {
|
||||
if id == currentUserID {
|
||||
return ErrCannotDeleteSelf
|
||||
}
|
||||
return s.store.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// ChangePassword changes a user's password after verifying the old password.
|
||||
func (s *Service) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
|
||||
user, err := s.store.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify old password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldPassword)); err != nil {
|
||||
return ErrPasswordMismatch
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if err := s.validatePassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), s.config.BcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
user.PasswordHash = string(passwordHash)
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
|
||||
return s.store.Update(ctx, user)
|
||||
}
|
||||
|
||||
// ResetPassword allows an admin to reset a user's password without knowing the old password.
|
||||
func (s *Service) ResetPassword(ctx context.Context, userID, newPassword string) error {
|
||||
user, err := s.store.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if err := s.validatePassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), s.config.BcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
user.PasswordHash = string(passwordHash)
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
|
||||
return s.store.Update(ctx, user)
|
||||
}
|
||||
|
||||
// EnsureAdminUser creates the admin user if no users exist.
|
||||
// Returns the generated password if a new admin was created.
|
||||
func (s *Service) EnsureAdminUser(ctx context.Context, username, password string) (string, bool, error) {
|
||||
count, err := s.store.Count(ctx)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("failed to count users: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// Generate password if not provided
|
||||
generatedPassword := password
|
||||
if generatedPassword == "" {
|
||||
generatedPassword, err = generateSecurePassword(16)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("failed to generate password: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.CreateUser(ctx, CreateUserInput{
|
||||
Username: username,
|
||||
Password: generatedPassword,
|
||||
Role: auth.RoleAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
// Handle race condition: another process may have created the admin user
|
||||
if errors.Is(err, auth.ErrUserAlreadyExists) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("failed to create admin user: %w", err)
|
||||
}
|
||||
|
||||
return generatedPassword, true, nil
|
||||
}
|
||||
|
||||
// validatePassword checks if a password meets the minimum requirements.
|
||||
func (s *Service) validatePassword(password string) error {
|
||||
if len(password) < minPasswordLength {
|
||||
return fmt.Errorf("%w: minimum length is %d characters", ErrWeakPassword, minPasswordLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSecurePassword returns a URL-safe base64-encoded string of the requested length
|
||||
// built from cryptographically secure random bytes. It returns an error if a secure random
|
||||
// source cannot be read.
|
||||
func generateSecurePassword(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes)[:length], nil
|
||||
}
|
||||
512
internal/service/auth/service_test.go
Normal file
512
internal/service/auth/service_test.go
Normal file
@ -0,0 +1,512 @@
|
||||
// Copyright (C) 2024 Yota Hamada
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dagu-org/dagu/internal/auth"
|
||||
"github.com/dagu-org/dagu/internal/persistence/fileuser"
|
||||
)
|
||||
|
||||
func setupTestService(t *testing.T) (*Service, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "auth-service-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
store, err := fileuser.New(tmpDir)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
|
||||
config := Config{
|
||||
TokenSecret: "test-secret-key-for-jwt-signing",
|
||||
TokenTTL: time.Hour,
|
||||
BcryptCost: 4, // Low cost for faster tests
|
||||
}
|
||||
|
||||
svc := New(store, config)
|
||||
cleanup := func() {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return svc, cleanup
|
||||
}
|
||||
|
||||
func TestService_CreateUser(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.CreateUser(ctx, CreateUserInput{
|
||||
Username: "testuser",
|
||||
Password: "password123",
|
||||
Role: auth.RoleManager,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser() error = %v", err)
|
||||
}
|
||||
|
||||
if user.Username != "testuser" {
|
||||
t.Errorf("CreateUser() username = %v, want %v", user.Username, "testuser")
|
||||
}
|
||||
if user.Role != auth.RoleManager {
|
||||
t.Errorf("CreateUser() role = %v, want %v", user.Role, auth.RoleManager)
|
||||
}
|
||||
if user.PasswordHash == "" {
|
||||
t.Error("CreateUser() password hash should not be empty")
|
||||
}
|
||||
if user.PasswordHash == "password123" {
|
||||
t.Error("CreateUser() password should be hashed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_CreateUser_WeakPassword(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.CreateUser(ctx, CreateUserInput{
|
||||
Username: "testuser",
|
||||
Password: "short", // Too short
|
||||
Role: auth.RoleViewer,
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("CreateUser() with weak password should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Authenticate(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user
|
||||
_, err := svc.CreateUser(ctx, CreateUserInput{
|
||||
Username: "testuser",
|
||||
Password: "password123",
|
||||
Role: auth.RoleAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser() error = %v", err)
|
||||
}
|
||||
|
||||
// Test successful authentication
|
||||
user, err := svc.Authenticate(ctx, "testuser", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate() error = %v", err)
|
||||
}
|
||||
if user.Username != "testuser" {
|
||||
t.Errorf("Authenticate() username = %v, want %v", user.Username, "testuser")
|
||||
}
|
||||
|
||||
// Test wrong password
|
||||
_, err = svc.Authenticate(ctx, "testuser", "wrongpassword")
|
||||
if err != ErrInvalidCredentials {
|
||||
t.Errorf("Authenticate() with wrong password error = %v, want %v", err, ErrInvalidCredentials)
|
||||
}
|
||||
|
||||
// Test non-existent user
|
||||
_, err = svc.Authenticate(ctx, "nonexistent", "password123")
|
||||
if err != ErrInvalidCredentials {
|
||||
t.Errorf("Authenticate() with non-existent user error = %v, want %v", err, ErrInvalidCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GenerateAndValidateToken(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user
|
||||
user, err := svc.CreateUser(ctx, CreateUserInput{
|
||||
Username: "testuser",
|
||||
Password: "password123",
|
||||
Role: auth.RoleManager,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser() error = %v", err)
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenResult, err := svc.GenerateToken(user)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() error = %v", err)
|
||||
}
|
||||
if tokenResult.Token == "" {
|
||||
t.Error("GenerateToken() returned empty token")
|
||||
}
|
||||
if tokenResult.ExpiresAt.IsZero() {
|
||||
t.Error("GenerateToken() returned zero expiry time")
|
||||
}
|
||||
|
||||
// Validate token
|
||||
claims, err := svc.ValidateToken(tokenResult.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken() error = %v", err)
|
||||
}
|
||||
if claims.UserID != user.ID {
|
||||
t.Errorf("ValidateToken() userID = %v, want %v", claims.UserID, user.ID)
|
||||
}
|
||||
if claims.Username != user.Username {
|
||||
t.Errorf("ValidateToken() username = %v, want %v", claims.Username, user.Username)
|
||||
}
|
||||
if claims.Role != user.Role {
|
||||
t.Errorf("ValidateToken() role = %v, want %v", claims.Role, user.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateToken_Invalid(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
// Test invalid token
|
||||
_, err := svc.ValidateToken("invalid-token")
|
||||
if err != ErrInvalidToken {
|
||||
t.Errorf("ValidateToken() with invalid token error = %v, want %v", err, ErrInvalidToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetUserFromToken(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user
|
||||
user, err := svc.CreateUser(ctx, CreateUserInput{
|
||||
Username: "testuser",
|
||||
Password: "password123",
|
||||
Role: auth.RoleViewer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser() error = %v", err)
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenResult, err := svc.GenerateToken(user)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() error = %v", err)
|
||||
}
|
||||
|
||||
// Get user from token
|
||||
retrieved, err := svc.GetUserFromToken(ctx, tokenResult.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserFromToken() error = %v", err)
|
||||
}
|
||||
if retrieved.ID != user.ID {
|
||||
t.Errorf("GetUserFromToken() ID = %v, want %v", retrieved.ID, user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ChangePassword(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user
|
||||
user, err := svc.CreateUser(ctx, CreateUserInput{
|
||||
Username: "testuser",
|
||||
Password: "oldpassword1",
|
||||
Role: auth.RoleManager,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser() error = %v", err)
|
||||
}
|
||||
|
||||
// Change password
|
||||
err = svc.ChangePassword(ctx, user.ID, "oldpassword1", "newpassword1")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangePassword() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify old password no longer works
|
||||
_, err = svc.Authenticate(ctx, "testuser", "oldpassword1")
|
||||
if err != ErrInvalidCredentials {
|
||||
t.Errorf("Authenticate() with old password should fail")
|
||||
}
|
||||
|
||||
// Verify new password works
|
||||
_, err = svc.Authenticate(ctx, "testuser", "newpassword1")
|
||||
if err != nil {
|
||||
t.Errorf("Authenticate() with new password error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ChangePassword_WrongOldPassword(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user
|
||||
user, err := svc.CreateUser(ctx, CreateUserInput{
|
||||
Username: "testuser",
|
||||
Password: "password123",
|
||||
Role: auth.RoleViewer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser() error = %v", err)
|
||||
}
|
||||
|
||||
// Try to change with wrong old password
|
||||
err = svc.ChangePassword(ctx, user.ID, "wrongpassword", "newpassword1")
|
||||
if err != ErrPasswordMismatch {
|
||||
t.Errorf("ChangePassword() with wrong old password error = %v, want %v", err, ErrPasswordMismatch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EnsureAdminUser(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// First call should create admin
|
||||
password, created, err := svc.EnsureAdminUser(ctx, "admin", "adminpass1")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAdminUser() error = %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Error("EnsureAdminUser() should return created=true")
|
||||
}
|
||||
if password != "adminpass1" {
|
||||
t.Errorf("EnsureAdminUser() password = %v, want %v", password, "adminpass1")
|
||||
}
|
||||
|
||||
// Verify admin can authenticate
|
||||
_, err = svc.Authenticate(ctx, "admin", "adminpass1")
|
||||
if err != nil {
|
||||
t.Errorf("Authenticate() admin error = %v", err)
|
||||
}
|
||||
|
||||
// Second call should not create
|
||||
_, created, err = svc.EnsureAdminUser(ctx, "admin2", "adminpass2")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAdminUser() second call error = %v", err)
|
||||
}
|
||||
if created {
|
||||
t.Error("EnsureAdminUser() should return created=false when users exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EnsureAdminUser_GeneratePassword(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Call with empty password should generate one
|
||||
password, created, err := svc.EnsureAdminUser(ctx, "admin", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAdminUser() error = %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Error("EnsureAdminUser() should return created=true")
|
||||
}
|
||||
if password == "" {
|
||||
t.Error("EnsureAdminUser() should generate a password")
|
||||
}
|
||||
if len(password) < 8 {
|
||||
t.Error("Generated password should be at least 8 characters")
|
||||
}
|
||||
|
||||
// Verify admin can authenticate with generated password
|
||||
_, err = svc.Authenticate(ctx, "admin", password)
|
||||
if err != nil {
|
||||
t.Errorf("Authenticate() admin with generated password error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_DeleteUser(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user
|
||||
user, err := svc.CreateUser(ctx, CreateUserInput{
|
||||
Username: "testuser",
|
||||
Password: "password123",
|
||||
Role: auth.RoleManager,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser() error = %v", err)
|
||||
}
|
||||
|
||||
// Delete user
|
||||
err = svc.DeleteUser(ctx, user.ID, "other-user-id")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteUser() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify user is deleted
|
||||
_, err = svc.GetUser(ctx, user.ID)
|
||||
if err != auth.ErrUserNotFound {
|
||||
t.Errorf("GetUser() after delete error = %v, want %v", err, auth.ErrUserNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_DeleteUser_CannotDeleteSelf(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user
|
||||
user, err := svc.CreateUser(ctx, CreateUserInput{
|
||||
Username: "testuser",
|
||||
Password: "password123",
|
||||
Role: auth.RoleAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser() error = %v", err)
|
||||
}
|
||||
|
||||
// Try to delete self
|
||||
err = svc.DeleteUser(ctx, user.ID, user.ID)
|
||||
if err != ErrCannotDeleteSelf {
|
||||
t.Errorf("DeleteUser() self error = %v, want %v", err, ErrCannotDeleteSelf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateUser(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user
|
||||
user, err := svc.CreateUser(ctx, CreateUserInput{
|
||||
Username: "testuser",
|
||||
Password: "password123",
|
||||
Role: auth.RoleViewer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser() error = %v", err)
|
||||
}
|
||||
|
||||
// Update role
|
||||
newRole := auth.RoleAdmin
|
||||
updated, err := svc.UpdateUser(ctx, user.ID, UpdateUserInput{
|
||||
Role: &newRole,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUser() error = %v", err)
|
||||
}
|
||||
if updated.Role != auth.RoleAdmin {
|
||||
t.Errorf("UpdateUser() role = %v, want %v", updated.Role, auth.RoleAdmin)
|
||||
}
|
||||
|
||||
// Update username
|
||||
newUsername := "newusername"
|
||||
updated, err = svc.UpdateUser(ctx, user.ID, UpdateUserInput{
|
||||
Username: &newUsername,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUser() error = %v", err)
|
||||
}
|
||||
if updated.Username != "newusername" {
|
||||
t.Errorf("UpdateUser() username = %v, want %v", updated.Username, "newusername")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ListUsers(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create multiple users
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err := svc.CreateUser(ctx, CreateUserInput{
|
||||
Username: fmt.Sprintf("user%d", i),
|
||||
Password: "password123",
|
||||
Role: auth.RoleViewer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// List users
|
||||
users, err := svc.ListUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers() error = %v", err)
|
||||
}
|
||||
if len(users) != 3 {
|
||||
t.Errorf("ListUsers() returned %d users, want 3", len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ResetPassword(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user
|
||||
user, err := svc.CreateUser(ctx, CreateUserInput{
|
||||
Username: "testuser",
|
||||
Password: "oldpassword1",
|
||||
Role: auth.RoleManager,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser() error = %v", err)
|
||||
}
|
||||
|
||||
// Reset password (admin action, doesn't require old password)
|
||||
err = svc.ResetPassword(ctx, user.ID, "newpassword1")
|
||||
if err != nil {
|
||||
t.Fatalf("ResetPassword() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify old password no longer works
|
||||
_, err = svc.Authenticate(ctx, "testuser", "oldpassword1")
|
||||
if err != ErrInvalidCredentials {
|
||||
t.Errorf("Authenticate() with old password should fail")
|
||||
}
|
||||
|
||||
// Verify new password works
|
||||
_, err = svc.Authenticate(ctx, "testuser", "newpassword1")
|
||||
if err != nil {
|
||||
t.Errorf("Authenticate() with new password error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ResetPassword_WeakPassword(t *testing.T) {
|
||||
svc, cleanup := setupTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user
|
||||
user, err := svc.CreateUser(ctx, CreateUserInput{
|
||||
Username: "testuser",
|
||||
Password: "password123",
|
||||
Role: auth.RoleViewer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser() error = %v", err)
|
||||
}
|
||||
|
||||
// Try to reset with weak password
|
||||
err = svc.ResetPassword(ctx, user.ID, "weak")
|
||||
if err == nil {
|
||||
t.Error("ResetPassword() with weak password should return error")
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dagu-org/dagu/api/v1"
|
||||
"github.com/dagu-org/dagu/internal/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDAG(t *testing.T) {
|
||||
server := test.SetupServer(t)
|
||||
|
||||
// Create a new DAG
|
||||
_ = server.Client().Post("/api/v1/dags", api.CreateDAGJSONRequestBody{
|
||||
Value: "test_dag",
|
||||
}).ExpectStatus(http.StatusCreated).Send(t)
|
||||
|
||||
// Fetch the created DAG with the list endpoint
|
||||
resp := server.Client().Get("/api/v1/dags?name=test_dag").ExpectStatus(http.StatusOK).Send(t)
|
||||
var apiResp api.ListDAGs200JSONResponse
|
||||
resp.Unmarshal(t, &apiResp)
|
||||
|
||||
require.Len(t, apiResp.DAGs, 1, "expected one DAG")
|
||||
|
||||
// Delete the created DAG
|
||||
_ = server.Client().Delete("/api/v1/dags/test_dag").ExpectStatus(http.StatusNoContent).Send(t)
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dagu-org/dagu/api/v1"
|
||||
"github.com/dagu-org/dagu/internal/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHealthCheck(t *testing.T) {
|
||||
server := test.SetupServer(t)
|
||||
resp := server.Client().Get("/api/v1/health").ExpectStatus(http.StatusOK).Send(t)
|
||||
|
||||
var healthResp api.HealthResponse
|
||||
resp.Unmarshal(t, &healthResp)
|
||||
|
||||
require.Equal(t, api.HealthResponseStatusHealthy, healthResp.Status, "expected status 'ok'")
|
||||
}
|
||||
@ -11,15 +11,17 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/dagu-org/dagu/api/v2"
|
||||
"github.com/dagu-org/dagu/internal/auth"
|
||||
"github.com/dagu-org/dagu/internal/common/cmdutil"
|
||||
"github.com/dagu-org/dagu/internal/common/config"
|
||||
"github.com/dagu-org/dagu/internal/common/logger"
|
||||
"github.com/dagu-org/dagu/internal/common/logger/tag"
|
||||
"github.com/dagu-org/dagu/internal/core/execution"
|
||||
"github.com/dagu-org/dagu/internal/runtime"
|
||||
authservice "github.com/dagu-org/dagu/internal/service/auth"
|
||||
"github.com/dagu-org/dagu/internal/service/coordinator"
|
||||
"github.com/dagu-org/dagu/internal/service/frontend/api/pathutil"
|
||||
"github.com/dagu-org/dagu/internal/service/frontend/auth"
|
||||
frontendauth "github.com/dagu-org/dagu/internal/service/frontend/auth"
|
||||
"github.com/dagu-org/dagu/internal/service/resource"
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
@ -45,12 +47,45 @@ type API struct {
|
||||
serviceRegistry execution.ServiceRegistry
|
||||
subCmdBuilder *runtime.SubCmdBuilder
|
||||
resourceService *resource.Service
|
||||
authService AuthService
|
||||
}
|
||||
|
||||
// AuthService defines the interface for authentication operations.
|
||||
// This allows the API to work with or without auth service being configured.
|
||||
type AuthService interface {
|
||||
Authenticate(ctx context.Context, username, password string) (*auth.User, error)
|
||||
GenerateToken(user *auth.User) (*authservice.TokenResult, error)
|
||||
GetUserFromToken(ctx context.Context, token string) (*auth.User, error)
|
||||
CreateUser(ctx context.Context, input authservice.CreateUserInput) (*auth.User, error)
|
||||
GetUser(ctx context.Context, id string) (*auth.User, error)
|
||||
ListUsers(ctx context.Context) ([]*auth.User, error)
|
||||
UpdateUser(ctx context.Context, id string, input authservice.UpdateUserInput) (*auth.User, error)
|
||||
DeleteUser(ctx context.Context, id string, currentUserID string) error
|
||||
ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error
|
||||
ResetPassword(ctx context.Context, userID, newPassword string) error
|
||||
}
|
||||
|
||||
// APIOption is a functional option for configuring the API.
|
||||
type APIOption func(*API)
|
||||
|
||||
// WithAuthService returns an APIOption that sets the API's AuthService.
|
||||
// When applied, the provided AuthService will be used by API methods and middleware; passing nil disables authentication.
|
||||
func WithAuthService(as AuthService) APIOption {
|
||||
return func(a *API) {
|
||||
a.authService = as
|
||||
}
|
||||
}
|
||||
|
||||
// New constructs an API instance wired with the provided DAG, DAG-run, queue and proc stores,
|
||||
// runtime manager, configuration, coordinator client, service registry, metrics registry, and resource service.
|
||||
// It also builds the internal remote node map from cfg.Server.RemoteNodes and initializes the sub-command
|
||||
// builder and API base path from the provided configuration.
|
||||
// New constructs an *API configured with the provided stores, runtime manager,
|
||||
// configuration, coordinator client, service registry, Prometheus registry,
|
||||
// and resource service.
|
||||
//
|
||||
// It builds the API instance (including the remote node map and base path) and
|
||||
// applies any supplied APIOption functions to customize the instance before
|
||||
// returning it.
|
||||
func New(
|
||||
dr execution.DAGStore,
|
||||
drs execution.DAGRunStore,
|
||||
@ -62,13 +97,14 @@ func New(
|
||||
sr execution.ServiceRegistry,
|
||||
mr *prometheus.Registry,
|
||||
rs *resource.Service,
|
||||
opts ...APIOption,
|
||||
) *API {
|
||||
remoteNodes := make(map[string]config.RemoteNode)
|
||||
for _, n := range cfg.Server.RemoteNodes {
|
||||
remoteNodes[n.Name] = n
|
||||
}
|
||||
|
||||
return &API{
|
||||
a := &API{
|
||||
dagStore: dr,
|
||||
dagRunStore: drs,
|
||||
queueStore: qs,
|
||||
@ -84,6 +120,12 @@ func New(
|
||||
metricsRegistry: mr,
|
||||
resourceService: rs,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(a)
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *API) ConfigureRoutes(ctx context.Context, r chi.Router, baseURL string) error {
|
||||
@ -150,7 +192,7 @@ func (a *API) ConfigureRoutes(ctx context.Context, r chi.Router, baseURL string)
|
||||
} else {
|
||||
authConfig = evaluatedAuth
|
||||
}
|
||||
authOptions := auth.Options{
|
||||
authOptions := frontendauth.Options{
|
||||
Realm: "restricted",
|
||||
APITokenEnabled: authConfig.Token.Value != "",
|
||||
APIToken: authConfig.Token.Value,
|
||||
@ -159,13 +201,14 @@ func (a *API) ConfigureRoutes(ctx context.Context, r chi.Router, baseURL string)
|
||||
PublicPaths: []string{
|
||||
pathutil.BuildPublicEndpointPath(basePath, "api/v2/health"),
|
||||
pathutil.BuildPublicEndpointPath(basePath, "api/v2/metrics"),
|
||||
pathutil.BuildPublicEndpointPath(basePath, "api/v2/auth/login"),
|
||||
},
|
||||
}
|
||||
|
||||
// Initialize OIDC if enabled
|
||||
authOIDC := authConfig.OIDC
|
||||
if authOIDC.ClientId != "" && authOIDC.ClientSecret != "" && authOIDC.Issuer != "" {
|
||||
oidcCfg, err := auth.InitVerifierAndConfig(authOIDC)
|
||||
oidcCfg, err := frontendauth.InitVerifierAndConfig(authOIDC)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize OIDC: %w", err)
|
||||
}
|
||||
@ -176,13 +219,30 @@ func (a *API) ConfigureRoutes(ctx context.Context, r chi.Router, baseURL string)
|
||||
authOptions.OIDCConfig = oidcCfg.Config
|
||||
}
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.Middleware(authOptions))
|
||||
r.Use(WithRemoteNode(a.remoteNodes, a.apiBasePath))
|
||||
// Apply authentication middleware based on auth mode
|
||||
if authConfig.Mode == config.AuthModeBuiltin {
|
||||
if a.authService == nil {
|
||||
return fmt.Errorf("builtin auth mode configured but auth service not initialized")
|
||||
}
|
||||
r.Group(func(r chi.Router) {
|
||||
// For builtin auth, use JWT-based authentication
|
||||
// The BuiltinAuthMiddleware validates JWT tokens and injects user into context
|
||||
r.Use(frontendauth.BuiltinAuthMiddleware(a.authService, authOptions.PublicPaths))
|
||||
r.Use(WithRemoteNode(a.remoteNodes, a.apiBasePath))
|
||||
|
||||
handler := api.NewStrictHandlerWithOptions(a, nil, options)
|
||||
r.Mount("/", api.Handler(handler))
|
||||
})
|
||||
handler := api.NewStrictHandlerWithOptions(a, nil, options)
|
||||
r.Mount("/", api.Handler(handler))
|
||||
})
|
||||
} else {
|
||||
r.Group(func(r chi.Router) {
|
||||
// For other auth modes (basic, token, OIDC), use the legacy middleware
|
||||
r.Use(frontendauth.Middleware(authOptions))
|
||||
r.Use(WithRemoteNode(a.remoteNodes, a.apiBasePath))
|
||||
|
||||
handler := api.NewStrictHandlerWithOptions(a, nil, options)
|
||||
r.Mount("/", api.Handler(handler))
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -243,6 +303,91 @@ func (a *API) isAllowed(perm config.Permission) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireAdmin checks if the current user has admin role.
|
||||
// Returns nil if auth is not enabled (authService is nil).
|
||||
func (a *API) requireAdmin(ctx context.Context) error {
|
||||
if a.authService == nil {
|
||||
return nil // Auth not enabled, allow access
|
||||
}
|
||||
user, ok := auth.UserFromContext(ctx)
|
||||
if !ok {
|
||||
return &Error{
|
||||
Code: api.ErrorCodeUnauthorized,
|
||||
Message: "Authentication required",
|
||||
HTTPStatus: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
if !user.Role.IsAdmin() {
|
||||
return &Error{
|
||||
Code: api.ErrorCodeForbidden,
|
||||
Message: "Insufficient permissions",
|
||||
HTTPStatus: http.StatusForbidden,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireWrite checks if the current user can write (create/edit/delete) DAGs.
|
||||
// Returns nil if auth is not enabled (authService is nil).
|
||||
func (a *API) requireWrite(ctx context.Context) error {
|
||||
if a.authService == nil {
|
||||
return nil // Auth not enabled, allow access
|
||||
}
|
||||
user, ok := auth.UserFromContext(ctx)
|
||||
if !ok {
|
||||
return &Error{
|
||||
Code: api.ErrorCodeUnauthorized,
|
||||
Message: "Authentication required",
|
||||
HTTPStatus: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
if !user.Role.CanWrite() {
|
||||
return &Error{
|
||||
Code: api.ErrorCodeForbidden,
|
||||
Message: "Insufficient permissions",
|
||||
HTTPStatus: http.StatusForbidden,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireExecute checks if the current user can execute (run/stop) DAGs.
|
||||
// Returns nil if auth is not enabled (authService is nil).
|
||||
func (a *API) requireExecute(ctx context.Context) error {
|
||||
if a.authService == nil {
|
||||
return nil // Auth not enabled, allow access
|
||||
}
|
||||
user, ok := auth.UserFromContext(ctx)
|
||||
if !ok {
|
||||
return &Error{
|
||||
Code: api.ErrorCodeUnauthorized,
|
||||
Message: "Authentication required",
|
||||
HTTPStatus: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
if !user.Role.CanExecute() {
|
||||
return &Error{
|
||||
Code: api.ErrorCodeForbidden,
|
||||
Message: "Insufficient permissions",
|
||||
HTTPStatus: http.StatusForbidden,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireUserManagement checks if user management is enabled.
|
||||
func (a *API) requireUserManagement() error {
|
||||
if a.authService == nil {
|
||||
return &Error{
|
||||
Code: api.ErrorCodeUnauthorized,
|
||||
Message: "User management is not enabled",
|
||||
HTTPStatus: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ptrOf returns a pointer to v, or nil if v is the zero value for its type.
|
||||
func ptrOf[T any](v T) *T {
|
||||
if reflect.ValueOf(v).IsZero() {
|
||||
return nil
|
||||
|
||||
134
internal/service/frontend/api/v2/auth.go
Normal file
134
internal/service/frontend/api/v2/auth.go
Normal file
@ -0,0 +1,134 @@
|
||||
// Copyright (C) 2024 Yota Hamada
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/dagu-org/dagu/api/v2"
|
||||
"github.com/dagu-org/dagu/internal/auth"
|
||||
authservice "github.com/dagu-org/dagu/internal/service/auth"
|
||||
)
|
||||
|
||||
// Login authenticates a user and returns a JWT token.
|
||||
func (a *API) Login(ctx context.Context, request api.LoginRequestObject) (api.LoginResponseObject, error) {
|
||||
if a.authService == nil {
|
||||
return api.Login401JSONResponse{
|
||||
Code: api.ErrorCodeUnauthorized,
|
||||
Message: "Authentication is not enabled",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if request.Body == nil {
|
||||
return api.Login401JSONResponse{
|
||||
Code: api.ErrorCodeUnauthorized,
|
||||
Message: "Invalid request body",
|
||||
}, nil
|
||||
}
|
||||
|
||||
user, err := a.authService.Authenticate(ctx, request.Body.Username, request.Body.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, authservice.ErrInvalidCredentials) {
|
||||
return api.Login401JSONResponse{
|
||||
Code: api.ErrorCodeUnauthorized,
|
||||
Message: "Invalid username or password",
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tokenResult, err := a.authService.GenerateToken(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return api.Login200JSONResponse{
|
||||
Token: tokenResult.Token,
|
||||
ExpiresAt: tokenResult.ExpiresAt,
|
||||
User: toAPIUser(user),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCurrentUser returns the currently authenticated user.
|
||||
func (a *API) GetCurrentUser(ctx context.Context, _ api.GetCurrentUserRequestObject) (api.GetCurrentUserResponseObject, error) {
|
||||
user, ok := auth.UserFromContext(ctx)
|
||||
if !ok {
|
||||
return api.GetCurrentUser401JSONResponse{
|
||||
Code: api.ErrorCodeUnauthorized,
|
||||
Message: "Not authenticated",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return api.GetCurrentUser200JSONResponse{
|
||||
User: toAPIUser(user),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ChangePassword allows the authenticated user to change their own password.
|
||||
func (a *API) ChangePassword(ctx context.Context, request api.ChangePasswordRequestObject) (api.ChangePasswordResponseObject, error) {
|
||||
if a.authService == nil {
|
||||
return api.ChangePassword401JSONResponse{
|
||||
Code: api.ErrorCodeUnauthorized,
|
||||
Message: "Authentication is not enabled",
|
||||
}, nil
|
||||
}
|
||||
|
||||
user, ok := auth.UserFromContext(ctx)
|
||||
if !ok {
|
||||
return api.ChangePassword401JSONResponse{
|
||||
Code: api.ErrorCodeUnauthorized,
|
||||
Message: "Not authenticated",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if request.Body == nil {
|
||||
return api.ChangePassword400JSONResponse{
|
||||
Code: api.ErrorCodeBadRequest,
|
||||
Message: "Invalid request body",
|
||||
}, nil
|
||||
}
|
||||
|
||||
err := a.authService.ChangePassword(ctx, user.ID, request.Body.CurrentPassword, request.Body.NewPassword)
|
||||
if err != nil {
|
||||
if errors.Is(err, authservice.ErrPasswordMismatch) {
|
||||
return api.ChangePassword401JSONResponse{
|
||||
Code: api.ErrorCodeUnauthorized,
|
||||
Message: "Current password is incorrect",
|
||||
}, nil
|
||||
}
|
||||
if errors.Is(err, authservice.ErrWeakPassword) {
|
||||
return api.ChangePassword400JSONResponse{
|
||||
Code: api.ErrorCodeBadRequest,
|
||||
Message: "New password does not meet security requirements",
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return api.ChangePassword200JSONResponse{
|
||||
Message: "Password changed successfully",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// toAPIUser converts a core auth.User into its API representation.
|
||||
// The provided user must be non-nil.
|
||||
func toAPIUser(user *auth.User) api.User {
|
||||
return api.User{
|
||||
Id: user.ID,
|
||||
Username: user.Username,
|
||||
Role: api.UserRole(user.Role),
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// preserving the input order.
|
||||
func toAPIUsers(users []*auth.User) []api.User {
|
||||
result := make([]api.User, len(users))
|
||||
for i, u := range users {
|
||||
result[i] = toAPIUser(u)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -26,6 +26,9 @@ func (a *API) ExecuteDAGRunFromSpec(ctx context.Context, request api.ExecuteDAGR
|
||||
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireExecute(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if request.Body == nil || request.Body.Spec == "" {
|
||||
return nil, &Error{
|
||||
@ -83,6 +86,9 @@ func (a *API) EnqueueDAGRunFromSpec(ctx context.Context, request api.EnqueueDAGR
|
||||
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireExecute(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if request.Body == nil || request.Body.Spec == "" {
|
||||
return nil, &Error{
|
||||
@ -384,6 +390,9 @@ func (a *API) UpdateDAGRunStepStatus(ctx context.Context, request api.UpdateDAGR
|
||||
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireExecute(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ref := execution.NewDAGRunRef(request.Name, request.DagRunId)
|
||||
dagStatus, err := a.dagRunMgr.GetSavedStatus(ctx, ref)
|
||||
@ -563,6 +572,9 @@ func (a *API) UpdateSubDAGRunStepStatus(ctx context.Context, request api.UpdateS
|
||||
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireExecute(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
root := execution.NewDAGRunRef(request.Name, request.DagRunId)
|
||||
dagStatus, err := a.dagRunMgr.FindSubDAGRunStatus(ctx, root, request.SubDAGRunId)
|
||||
@ -615,6 +627,9 @@ func (a *API) RetryDAGRun(ctx context.Context, request api.RetryDAGRunRequestObj
|
||||
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireExecute(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attempt, err := a.dagRunStore.FindAttempt(ctx, execution.NewDAGRunRef(request.Name, request.DagRunId))
|
||||
if err != nil {
|
||||
@ -671,6 +686,9 @@ func (a *API) TerminateDAGRun(ctx context.Context, request api.TerminateDAGRunRe
|
||||
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireExecute(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attempt, err := a.dagRunStore.FindAttempt(ctx, execution.NewDAGRunRef(request.Name, request.DagRunId))
|
||||
if err != nil {
|
||||
@ -714,6 +732,9 @@ func (a *API) DequeueDAGRun(ctx context.Context, request api.DequeueDAGRunReques
|
||||
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireExecute(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dagRun := execution.NewDAGRunRef(request.Name, request.DagRunId)
|
||||
attempt, err := a.dagRunStore.FindAttempt(ctx, dagRun)
|
||||
@ -755,6 +776,9 @@ func (a *API) RescheduleDAGRun(ctx context.Context, request api.RescheduleDAGRun
|
||||
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireExecute(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attempt, err := a.dagRunStore.FindAttempt(ctx, execution.NewDAGRunRef(request.Name, request.DagRunId))
|
||||
if err != nil {
|
||||
|
||||
@ -72,6 +72,9 @@ func (a *API) CreateNewDAG(ctx context.Context, request api.CreateNewDAGRequestO
|
||||
if err := a.isAllowed(config.PermissionWriteDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireWrite(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine spec to create with: provided spec or default template
|
||||
var yamlSpec []byte
|
||||
@ -126,6 +129,9 @@ func (a *API) DeleteDAG(ctx context.Context, request api.DeleteDAGRequestObject)
|
||||
if err := a.isAllowed(config.PermissionWriteDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireWrite(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err := a.dagStore.GetMetadata(ctx, request.FileName)
|
||||
if err != nil {
|
||||
@ -190,6 +196,9 @@ func (a *API) UpdateDAGSpec(ctx context.Context, request api.UpdateDAGSpecReques
|
||||
if err := a.isAllowed(config.PermissionWriteDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireWrite(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err := a.dagStore.UpdateSpec(ctx, request.FileName, []byte(request.Body.Spec))
|
||||
|
||||
@ -211,6 +220,9 @@ func (a *API) RenameDAG(ctx context.Context, request api.RenameDAGRequestObject)
|
||||
if err := a.isAllowed(config.PermissionWriteDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireWrite(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dag, err := a.dagStore.GetMetadata(ctx, request.FileName)
|
||||
if err != nil {
|
||||
@ -523,6 +535,9 @@ func (a *API) ExecuteDAG(ctx context.Context, request api.ExecuteDAGRequestObjec
|
||||
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireExecute(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dag, err := a.dagStore.GetDetails(ctx, request.FileName)
|
||||
if err != nil {
|
||||
@ -734,6 +749,9 @@ func (a *API) EnqueueDAGDAGRun(ctx context.Context, request api.EnqueueDAGDAGRun
|
||||
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireExecute(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dag, err := a.dagStore.GetDetails(ctx, request.FileName, spec.WithoutEval())
|
||||
if err != nil {
|
||||
@ -863,6 +881,9 @@ func (a *API) UpdateDAGSuspensionState(ctx context.Context, request api.UpdateDA
|
||||
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireExecute(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err := a.dagStore.GetMetadata(ctx, request.FileName)
|
||||
if err != nil {
|
||||
@ -914,6 +935,9 @@ func (a *API) StopAllDAGRuns(ctx context.Context, request api.StopAllDAGRunsRequ
|
||||
if err := a.isAllowed(config.PermissionRunDAGs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireExecute(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the DAG metadata to ensure it exists
|
||||
dag, err := a.dagStore.GetMetadata(ctx, request.FileName)
|
||||
|
||||
266
internal/service/frontend/api/v2/users.go
Normal file
266
internal/service/frontend/api/v2/users.go
Normal file
@ -0,0 +1,266 @@
|
||||
// Copyright (C) 2024 Yota Hamada
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/dagu-org/dagu/api/v2"
|
||||
"github.com/dagu-org/dagu/internal/auth"
|
||||
authservice "github.com/dagu-org/dagu/internal/service/auth"
|
||||
)
|
||||
|
||||
// ListUsers returns a list of all users. Requires admin role.
|
||||
func (a *API) ListUsers(ctx context.Context, _ api.ListUsersRequestObject) (api.ListUsersResponseObject, error) {
|
||||
if err := a.requireUserManagement(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users, err := a.authService.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return api.ListUsers200JSONResponse{
|
||||
Users: toAPIUsers(users),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user. Requires admin role.
|
||||
func (a *API) CreateUser(ctx context.Context, request api.CreateUserRequestObject) (api.CreateUserResponseObject, error) {
|
||||
if err := a.requireUserManagement(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if request.Body == nil {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeBadRequest,
|
||||
Message: "Invalid request body",
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
role, err := auth.ParseRole(string(request.Body.Role))
|
||||
if err != nil {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeBadRequest,
|
||||
Message: "Invalid role",
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
user, err := a.authService.CreateUser(ctx, authservice.CreateUserInput{
|
||||
Username: request.Body.Username,
|
||||
Password: request.Body.Password,
|
||||
Role: role,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUserAlreadyExists) {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeAlreadyExists,
|
||||
Message: "Username already exists",
|
||||
HTTPStatus: http.StatusConflict,
|
||||
}
|
||||
}
|
||||
if errors.Is(err, auth.ErrInvalidUsername) {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeBadRequest,
|
||||
Message: "Invalid username",
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
if errors.Is(err, authservice.ErrWeakPassword) {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeBadRequest,
|
||||
Message: "Password does not meet security requirements",
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return api.CreateUser201JSONResponse{
|
||||
User: toAPIUser(user),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUser returns a specific user by ID. Requires admin role.
|
||||
func (a *API) GetUser(ctx context.Context, request api.GetUserRequestObject) (api.GetUserResponseObject, error) {
|
||||
if err := a.requireUserManagement(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := a.authService.GetUser(ctx, request.UserId)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUserNotFound) {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeNotFound,
|
||||
Message: "User not found",
|
||||
HTTPStatus: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return api.GetUser200JSONResponse{
|
||||
User: toAPIUser(user),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates a user's information. Requires admin role.
|
||||
func (a *API) UpdateUser(ctx context.Context, request api.UpdateUserRequestObject) (api.UpdateUserResponseObject, error) {
|
||||
if err := a.requireUserManagement(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if request.Body == nil {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeBadRequest,
|
||||
Message: "Invalid request body",
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
input := authservice.UpdateUserInput{}
|
||||
if request.Body.Username != nil {
|
||||
input.Username = request.Body.Username
|
||||
}
|
||||
if request.Body.Role != nil {
|
||||
role, err := auth.ParseRole(string(*request.Body.Role))
|
||||
if err != nil {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeBadRequest,
|
||||
Message: "Invalid role",
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
input.Role = &role
|
||||
}
|
||||
|
||||
user, err := a.authService.UpdateUser(ctx, request.UserId, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUserNotFound) {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeNotFound,
|
||||
Message: "User not found",
|
||||
HTTPStatus: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
if errors.Is(err, auth.ErrUserAlreadyExists) {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeAlreadyExists,
|
||||
Message: "Username already exists",
|
||||
HTTPStatus: http.StatusConflict,
|
||||
}
|
||||
}
|
||||
if errors.Is(err, auth.ErrInvalidUsername) {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeBadRequest,
|
||||
Message: "Invalid username",
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return api.UpdateUser200JSONResponse{
|
||||
User: toAPIUser(user),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user account. Requires admin role. Cannot delete yourself.
|
||||
func (a *API) DeleteUser(ctx context.Context, request api.DeleteUserRequestObject) (api.DeleteUserResponseObject, error) {
|
||||
if err := a.requireUserManagement(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get current user to prevent self-deletion
|
||||
currentUser, ok := auth.UserFromContext(ctx)
|
||||
if !ok {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeUnauthorized,
|
||||
Message: "Not authenticated",
|
||||
HTTPStatus: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
|
||||
err := a.authService.DeleteUser(ctx, request.UserId, currentUser.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUserNotFound) {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeNotFound,
|
||||
Message: "User not found",
|
||||
HTTPStatus: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
if errors.Is(err, authservice.ErrCannotDeleteSelf) {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeForbidden,
|
||||
Message: "Cannot delete your own account",
|
||||
HTTPStatus: http.StatusForbidden,
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return api.DeleteUser204Response{}, nil
|
||||
}
|
||||
|
||||
// ResetUserPassword resets a user's password. Requires admin role.
|
||||
func (a *API) ResetUserPassword(ctx context.Context, request api.ResetUserPasswordRequestObject) (api.ResetUserPasswordResponseObject, error) {
|
||||
if err := a.requireUserManagement(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if request.Body == nil {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeBadRequest,
|
||||
Message: "Invalid request body",
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
err := a.authService.ResetPassword(ctx, request.UserId, request.Body.NewPassword)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUserNotFound) {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeNotFound,
|
||||
Message: "User not found",
|
||||
HTTPStatus: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
if errors.Is(err, authservice.ErrWeakPassword) {
|
||||
return nil, &Error{
|
||||
Code: api.ErrorCodeBadRequest,
|
||||
Message: "Password does not meet security requirements",
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return api.ResetUserPassword200JSONResponse{
|
||||
Message: "Password reset successfully",
|
||||
}, nil
|
||||
}
|
||||
162
internal/service/frontend/auth/builtin.go
Normal file
162
internal/service/frontend/auth/builtin.go
Normal file
@ -0,0 +1,162 @@
|
||||
// Copyright (C) 2024 Yota Hamada
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/dagu-org/dagu/internal/auth"
|
||||
)
|
||||
|
||||
// TokenValidator defines the interface for validating tokens and retrieving users.
|
||||
// This allows the middleware to work with any token validation implementation.
|
||||
type TokenValidator interface {
|
||||
GetUserFromToken(ctx context.Context, token string) (*auth.User, error)
|
||||
}
|
||||
|
||||
// BuiltinAuthMiddleware creates middleware that validates JWT tokens
|
||||
// and injects the authenticated user into the request context.
|
||||
// BuiltinAuthMiddleware returns an HTTP middleware that enforces authentication for requests
|
||||
// whose paths are not listed in publicPaths. For non-public requests it extracts a Bearer token,
|
||||
// validates it via the provided TokenValidator, and on success injects the authenticated user into
|
||||
// the request context; on failure it writes a 401 JSON error response.
|
||||
func BuiltinAuthMiddleware(svc TokenValidator, publicPaths []string) func(http.Handler) http.Handler {
|
||||
// Build a set for O(1) lookup
|
||||
publicSet := make(map[string]struct{}, len(publicPaths))
|
||||
for _, p := range publicPaths {
|
||||
publicSet[p] = struct{}{}
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if path is public
|
||||
if isPublicPath(r.URL.Path, publicSet) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token := extractBearerToken(r)
|
||||
if token == "" {
|
||||
writeAuthError(w, http.StatusUnauthorized, "auth.unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := svc.GetUserFromToken(r.Context(), token)
|
||||
if err != nil {
|
||||
writeAuthError(w, http.StatusUnauthorized, "auth.token_invalid", "Invalid or expired token")
|
||||
return
|
||||
}
|
||||
|
||||
// Inject user into context
|
||||
ctx := auth.WithUser(r.Context(), user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isPublicPath checks if the request path matches any public path.
|
||||
// plus variants with a trailing slash added or removed.
|
||||
func isPublicPath(path string, publicSet map[string]struct{}) bool {
|
||||
// Exact match
|
||||
if _, ok := publicSet[path]; ok {
|
||||
return true
|
||||
}
|
||||
// Try with trailing slash removed
|
||||
withoutSlash := strings.TrimSuffix(path, "/")
|
||||
if _, ok := publicSet[withoutSlash]; ok {
|
||||
return true
|
||||
}
|
||||
// Try with trailing slash added
|
||||
if path != "" && !strings.HasSuffix(path, "/") {
|
||||
if _, ok := publicSet[path+"/"]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RequireRole creates middleware that checks if the authenticated user
|
||||
// ("auth.forbidden").
|
||||
func RequireRole(roles ...auth.Role) func(http.Handler) http.Handler {
|
||||
roleSet := make(map[auth.Role]struct{}, len(roles))
|
||||
for _, r := range roles {
|
||||
roleSet[r] = struct{}{}
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeAuthError(w, http.StatusUnauthorized, "auth.unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if _, allowed := roleSet[user.Role]; !allowed {
|
||||
writeAuthError(w, http.StatusForbidden, "auth.forbidden", "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin returns a middleware that allows only authenticated users with the admin role.
|
||||
func RequireAdmin() func(http.Handler) http.Handler {
|
||||
return RequireRole(auth.RoleAdmin)
|
||||
}
|
||||
|
||||
// RequireWrite returns middleware that permits requests only for users with the Admin or Manager role.
|
||||
func RequireWrite() func(http.Handler) http.Handler {
|
||||
return RequireRole(auth.RoleAdmin, auth.RoleManager)
|
||||
}
|
||||
|
||||
// RequireExecute is middleware that permits requests only from users with the Admin, Manager, or Operator role.
|
||||
func RequireExecute() func(http.Handler) http.Handler {
|
||||
return RequireRole(auth.RoleAdmin, auth.RoleManager, auth.RoleOperator)
|
||||
}
|
||||
|
||||
// extractBearerToken extracts the bearer token from the request's Authorization header.
|
||||
// It returns the token string without the "Bearer " prefix, or an empty string if the header is missing or not a Bearer token.
|
||||
func extractBearerToken(r *http.Request) string {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
const bearerPrefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimPrefix(authHeader, bearerPrefix)
|
||||
}
|
||||
|
||||
// ErrorResponse represents an error response.
|
||||
type ErrorResponse struct {
|
||||
Error ErrorDetail `json:"error"`
|
||||
}
|
||||
|
||||
// ErrorDetail contains error details.
|
||||
type ErrorDetail struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// writeAuthError writes an HTTP JSON error response with the provided status and error details.
|
||||
// It sets the Content-Type header to "application/json", writes the HTTP status, and encodes an
|
||||
// ErrorResponse containing the given code and message.
|
||||
func writeAuthError(w http.ResponseWriter, status int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(ErrorResponse{
|
||||
Error: ErrorDetail{
|
||||
Code: code,
|
||||
Message: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -20,7 +20,9 @@ import (
|
||||
"github.com/dagu-org/dagu/internal/common/logger"
|
||||
"github.com/dagu-org/dagu/internal/common/logger/tag"
|
||||
"github.com/dagu-org/dagu/internal/core/execution"
|
||||
"github.com/dagu-org/dagu/internal/persistence/fileuser"
|
||||
"github.com/dagu-org/dagu/internal/runtime"
|
||||
authservice "github.com/dagu-org/dagu/internal/service/auth"
|
||||
"github.com/dagu-org/dagu/internal/service/coordinator"
|
||||
apiv1 "github.com/dagu-org/dagu/internal/service/frontend/api/v1"
|
||||
apiv2 "github.com/dagu-org/dagu/internal/service/frontend/api/v2"
|
||||
@ -45,14 +47,30 @@ type Server struct {
|
||||
|
||||
// NewServer constructs a Server configured from cfg and the provided stores, managers, and services.
|
||||
// It extracts remote node names from cfg.Server.RemoteNodes, initializes apiV1 and apiV2 with the given dependencies, and populates the Server's funcsConfig fields from cfg.
|
||||
func NewServer(cfg *config.Config, dr execution.DAGStore, drs execution.DAGRunStore, qs execution.QueueStore, ps execution.ProcStore, drm runtime.Manager, cc coordinator.Client, sr execution.ServiceRegistry, mr *prometheus.Registry, rs *resource.Service) *Server {
|
||||
// Returns an error if a configured auth service fails to initialize (fail-fast behavior).
|
||||
func NewServer(cfg *config.Config, dr execution.DAGStore, drs execution.DAGRunStore, qs execution.QueueStore, ps execution.ProcStore, drm runtime.Manager, cc coordinator.Client, sr execution.ServiceRegistry, mr *prometheus.Registry, rs *resource.Service) (*Server, error) {
|
||||
var remoteNodes []string
|
||||
for _, n := range cfg.Server.RemoteNodes {
|
||||
remoteNodes = append(remoteNodes, n.Name)
|
||||
}
|
||||
|
||||
// Build API options
|
||||
var apiOpts []apiv2.APIOption
|
||||
|
||||
// Initialize auth service if builtin mode is enabled
|
||||
if cfg.Server.Auth.Mode == config.AuthModeBuiltin {
|
||||
authSvc, err := initBuiltinAuthService(cfg)
|
||||
if err != nil {
|
||||
// Fail fast: if auth is configured but fails to initialize, return error
|
||||
// to prevent server from running without expected authentication
|
||||
return nil, fmt.Errorf("failed to initialize builtin auth service: %w", err)
|
||||
}
|
||||
apiOpts = append(apiOpts, apiv2.WithAuthService(authSvc))
|
||||
}
|
||||
|
||||
return &Server{
|
||||
apiV1: apiv1.New(dr, drs, drm, cfg),
|
||||
apiV2: apiv2.New(dr, drs, qs, ps, drm, cfg, cc, sr, mr, rs),
|
||||
apiV2: apiv2.New(dr, drs, qs, ps, drm, cfg, cc, sr, mr, rs, apiOpts...),
|
||||
config: cfg,
|
||||
funcsConfig: funcsConfig{
|
||||
NavbarColor: cfg.UI.NavbarColor,
|
||||
@ -65,8 +83,65 @@ func NewServer(cfg *config.Config, dr execution.DAGStore, drs execution.DAGRunSt
|
||||
RemoteNodes: remoteNodes,
|
||||
Permissions: cfg.Server.Permissions,
|
||||
Paths: cfg.Paths,
|
||||
AuthMode: cfg.Server.Auth.Mode,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// initBuiltinAuthService creates a file-based user store, constructs the builtin
|
||||
// authentication service, and ensures a default admin user exists.
|
||||
// If the admin password is auto-generated, the password is printed to stdout.
|
||||
// It returns the initialized auth service or an error if any step fails.
|
||||
func initBuiltinAuthService(cfg *config.Config) (*authservice.Service, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Validate token secret is configured
|
||||
if cfg.Server.Auth.Builtin.Token.Secret == "" {
|
||||
return nil, fmt.Errorf("builtin auth requires a non-empty token secret (set DAGU_AUTH_TOKEN_SECRET or server.auth.builtin.token.secret)")
|
||||
}
|
||||
|
||||
// Create file-based user store
|
||||
userStore, err := fileuser.New(cfg.Paths.UsersDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create user store: %w", err)
|
||||
}
|
||||
|
||||
// Create auth service with configuration
|
||||
authConfig := authservice.Config{
|
||||
TokenSecret: cfg.Server.Auth.Builtin.Token.Secret,
|
||||
TokenTTL: cfg.Server.Auth.Builtin.Token.TTL,
|
||||
}
|
||||
authSvc := authservice.New(userStore, authConfig)
|
||||
|
||||
// Ensure admin user exists
|
||||
password, created, err := authSvc.EnsureAdminUser(
|
||||
ctx,
|
||||
cfg.Server.Auth.Builtin.Admin.Username,
|
||||
cfg.Server.Auth.Builtin.Admin.Password,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to ensure admin user: %w", err)
|
||||
}
|
||||
|
||||
if created {
|
||||
if cfg.Server.Auth.Builtin.Admin.Password == "" {
|
||||
// Password was auto-generated, print to stdout (not to structured logs)
|
||||
// which may be shipped to external systems)
|
||||
fmt.Printf("\n"+
|
||||
"================================================================================\n"+
|
||||
" ADMIN USER CREATED\n"+
|
||||
" Username: %s\n"+
|
||||
" Password: %s\n"+
|
||||
" NOTE: Please change this password immediately!\n"+
|
||||
"================================================================================\n\n",
|
||||
cfg.Server.Auth.Builtin.Admin.Username, password)
|
||||
} else {
|
||||
logger.Info(ctx, "Created admin user",
|
||||
slog.String("username", cfg.Server.Auth.Builtin.Admin.Username))
|
||||
}
|
||||
}
|
||||
|
||||
return authSvc, nil
|
||||
}
|
||||
|
||||
// Serve starts the HTTP server and configures routes
|
||||
@ -235,6 +310,12 @@ func (srv *Server) setupAPIRoutes(ctx context.Context, r *chi.Mux, apiV1BasePath
|
||||
var setupErr error
|
||||
|
||||
r.Route(apiV1BasePath, func(r chi.Router) {
|
||||
if srv.config.Server.Auth.Mode != config.AuthModeNone {
|
||||
// v1 API is not available in auth mode - it doesn't support authentication
|
||||
logger.Info(ctx, "Authentication enabled: V1 API is disabled, use V2 API instead",
|
||||
slog.String("authMode", string(srv.config.Server.Auth.Mode)))
|
||||
return
|
||||
}
|
||||
url := fmt.Sprintf("%s://%s:%d%s", schema, srv.config.Server.Host, srv.config.Server.Port, apiV1BasePath)
|
||||
if err := srv.apiV1.ConfigureRoutes(r, url); err != nil {
|
||||
logger.Error(ctx, "Failed to configure v1 API routes", tag.Error(err))
|
||||
|
||||
@ -60,8 +60,10 @@ type funcsConfig struct {
|
||||
RemoteNodes []string
|
||||
Permissions map[config.Permission]bool
|
||||
Paths config.PathsConfig
|
||||
AuthMode config.AuthMode
|
||||
}
|
||||
|
||||
// and simple utility helpers for use inside HTML templates.
|
||||
func defaultFunctions(cfg funcsConfig) template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"defTitle": func(ip any) string {
|
||||
@ -134,6 +136,12 @@ func defaultFunctions(cfg funcsConfig) template.FuncMap {
|
||||
"pathConfigFileUsed": func() string {
|
||||
return cfg.Paths.ConfigFileUsed
|
||||
},
|
||||
"pathUsersDir": func() string {
|
||||
return cfg.Paths.UsersDir
|
||||
},
|
||||
"authMode": func() string {
|
||||
return string(cfg.AuthMode)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
tzOffsetInSec: +"{{ tzOffsetInSec }}",
|
||||
maxDashboardPageLimit: "{{ maxDashboardPageLimit }}",
|
||||
remoteNodes: "{{ remoteNodes }}",
|
||||
authMode: "{{ authMode }}",
|
||||
permissions: {
|
||||
writeDags: "{{ permissionsWriteDags }}" === "true",
|
||||
runDags: "{{ permissionsRunDags }}" === "true",
|
||||
@ -34,6 +35,7 @@
|
||||
procDir: "{{ pathProcDir }}",
|
||||
serviceRegistryDir: "{{ pathServiceRegistryDir }}",
|
||||
configFileUsed: "{{ pathConfigFileUsed }}",
|
||||
usersDir: "{{ pathUsersDir }}",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -64,8 +64,9 @@ func (srv *Server) runServer(t *testing.T) {
|
||||
)
|
||||
mr := telemetry.NewRegistry(collector)
|
||||
|
||||
server := frontend.NewServer(srv.Config, srv.DAGStore, srv.DAGRunStore, srv.QueueStore, srv.ProcStore, srv.DAGRunMgr, cc, srv.ServiceRegistry, mr, nil)
|
||||
err := server.Serve(srv.Context)
|
||||
server, err := frontend.NewServer(srv.Config, srv.DAGStore, srv.DAGRunStore, srv.QueueStore, srv.ProcStore, srv.DAGRunMgr, cc, srv.ServiceRegistry, mr, nil)
|
||||
require.NoError(t, err, "failed to create server")
|
||||
err = server.Serve(srv.Context)
|
||||
require.NoError(t, err, "failed to start server")
|
||||
}
|
||||
|
||||
|
||||
@ -47,7 +47,9 @@
|
||||
queueDir: '~/.local/share/dagu/data/queue',
|
||||
procDir: '~/.local/share/dagu/data/proc',
|
||||
serviceRegistryDir: '~/.local/share/dagu/data/services',
|
||||
usersDir: '~/.local/share/dagu/data/users',
|
||||
},
|
||||
authMode: 'builtin',
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -73,6 +73,7 @@
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
|
||||
@ -23,6 +23,9 @@ importers:
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-dropdown-menu':
|
||||
specifier: ^2.1.16
|
||||
version: 2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@ -1174,6 +1177,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dropdown-menu@2.1.16':
|
||||
resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.2':
|
||||
resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==}
|
||||
peerDependencies:
|
||||
@ -1227,6 +1243,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-menu@2.1.16':
|
||||
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.7':
|
||||
resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==}
|
||||
peerDependencies:
|
||||
@ -1240,6 +1269,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.8':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-portal@1.1.9':
|
||||
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
||||
peerDependencies:
|
||||
@ -1292,6 +1334,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11':
|
||||
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-select@2.2.5':
|
||||
resolution: {integrity: sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==}
|
||||
peerDependencies:
|
||||
@ -5648,6 +5703,21 @@ snapshots:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.8)(react@19.1.0)':
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
@ -5687,6 +5757,32 @@ snapshots:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-menu@2.1.16(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
react-remove-scroll: 2.7.1(@types/react@19.1.8)(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@ -5705,6 +5801,24 @@ snapshots:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/rect': 1.1.1
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@ -5744,6 +5858,23 @@ snapshots:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-select@2.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
|
||||
@ -6,6 +6,8 @@ import { AppBarContext } from './contexts/AppBarContext';
|
||||
import { Config, ConfigContext } from './contexts/ConfigContext';
|
||||
import { SearchStateProvider } from './contexts/SearchStateContext';
|
||||
import { UserPreferencesProvider } from './contexts/UserPreference';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import Layout from './layouts/Layout';
|
||||
import fetchJson from './lib/fetchJson';
|
||||
import Dashboard from './pages';
|
||||
@ -17,11 +19,22 @@ import DAGRunDetails from './pages/dag-runs/dag-run';
|
||||
import Queues from './pages/queues';
|
||||
import Workers from './pages/workers';
|
||||
import SystemStatus from './pages/system-status';
|
||||
import LoginPage from './pages/login';
|
||||
import UsersPage from './pages/users';
|
||||
|
||||
type Props = {
|
||||
config: Config;
|
||||
};
|
||||
|
||||
/**
|
||||
* Root application component that composes providers, routing, and global UI state.
|
||||
*
|
||||
* Initializes and persists the selected remote node, exposes app bar state and config
|
||||
* via context providers, and mounts public (login) and protected routes inside the app layout.
|
||||
*
|
||||
* @param config - Application configuration (e.g., `basePath`, `remoteNodes`) used to configure routing and available remote nodes.
|
||||
* @returns The top-level React element for the application.
|
||||
*/
|
||||
function App({ config }: Props) {
|
||||
const [title, setTitle] = React.useState<string>('');
|
||||
|
||||
@ -90,33 +103,57 @@ function App({ config }: Props) {
|
||||
>
|
||||
<ConfigContext.Provider value={config}>
|
||||
<UserPreferencesProvider>
|
||||
<SearchStateProvider>
|
||||
<ToastProvider>
|
||||
<BrowserRouter basename={config.basePath}>
|
||||
<Layout {...config}>
|
||||
<AuthProvider>
|
||||
<SearchStateProvider>
|
||||
<ToastProvider>
|
||||
<BrowserRouter basename={config.basePath}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/system-status" element={<SystemStatus />} />
|
||||
<Route path="/dags/" element={<DAGs />} />
|
||||
{/* Public route - Login page */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/dags/:fileName/:tab"
|
||||
element={<DAGDetails />}
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout {...config}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/system-status" element={<SystemStatus />} />
|
||||
<Route path="/dags/" element={<DAGs />} />
|
||||
<Route
|
||||
path="/dags/:fileName/:tab"
|
||||
element={<DAGDetails />}
|
||||
/>
|
||||
<Route path="/dags/:fileName/" element={<DAGDetails />} />
|
||||
<Route path="/search/" element={<Search />} />
|
||||
<Route path="/queues" element={<Queues />} />
|
||||
<Route path="/dag-runs" element={<DAGRuns />} />
|
||||
<Route
|
||||
path="/dag-runs/:name/:dagRunId"
|
||||
element={<DAGRunDetails />}
|
||||
/>
|
||||
<Route path="/workers" element={<Workers />} />
|
||||
{/* Admin-only route */}
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<UsersPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/dags/:fileName/" element={<DAGDetails />} />
|
||||
<Route path="/search/" element={<Search />} />
|
||||
<Route path="/queues" element={<Queues />} />
|
||||
<Route path="/dag-runs" element={<DAGRuns />} />
|
||||
<Route
|
||||
path="/dag-runs/:name/:dagRunId"
|
||||
element={<DAGRunDetails />}
|
||||
/>
|
||||
<Route path="/workers" element={<Workers />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
</SearchStateProvider>
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
</SearchStateProvider>
|
||||
</AuthProvider>
|
||||
</UserPreferencesProvider>
|
||||
</ConfigContext.Provider>
|
||||
</AppBarContext.Provider>
|
||||
@ -124,4 +161,4 @@ function App({ config }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
@ -24,6 +24,138 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/auth/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Authenticate user and obtain JWT token
|
||||
* @description Authenticates a user with username and password, returns a JWT token on success
|
||||
*/
|
||||
post: operations["login"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/auth/me": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get current authenticated user
|
||||
* @description Returns information about the currently authenticated user
|
||||
*/
|
||||
get: operations["getCurrentUser"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/auth/change-password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Change current user's password
|
||||
* @description Allows the authenticated user to change their own password
|
||||
*/
|
||||
post: operations["changePassword"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List all users
|
||||
* @description Returns a list of all users. Requires admin role.
|
||||
*/
|
||||
get: operations["listUsers"];
|
||||
put?: never;
|
||||
/**
|
||||
* Create a new user
|
||||
* @description Creates a new user account. Requires admin role.
|
||||
*/
|
||||
post: operations["createUser"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users/{userId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get user by ID
|
||||
* @description Returns a specific user by their ID. Requires admin role.
|
||||
*/
|
||||
get: operations["getUser"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Delete user
|
||||
* @description Deletes a user account. Requires admin role. Cannot delete yourself.
|
||||
*/
|
||||
delete: operations["deleteUser"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Update user
|
||||
* @description Updates a user's information. Requires admin role.
|
||||
*/
|
||||
patch: operations["updateUser"];
|
||||
trace?: never;
|
||||
};
|
||||
"/users/{userId}/reset-password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Reset user's password
|
||||
* @description Resets a user's password to a new value. Requires admin role.
|
||||
*/
|
||||
post: operations["resetUserPassword"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/workers": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -1299,11 +1431,93 @@ export interface components {
|
||||
/** Format: double */
|
||||
value: number;
|
||||
};
|
||||
/**
|
||||
* @description User role determining access permissions. admin: full access including user management, manager: DAG CRUD and execution, operator: DAG execution only, viewer: read-only
|
||||
* @enum {string}
|
||||
*/
|
||||
UserRole: UserRole;
|
||||
/** @description Request body for user login */
|
||||
LoginRequest: {
|
||||
/** @description User's username */
|
||||
username: string;
|
||||
/** @description User's password */
|
||||
password: string;
|
||||
};
|
||||
/** @description Response containing authentication token */
|
||||
LoginResponse: {
|
||||
/** @description JWT authentication token */
|
||||
token: string;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Token expiration timestamp
|
||||
*/
|
||||
expiresAt: string;
|
||||
user: components["schemas"]["User"];
|
||||
};
|
||||
/** @description Request body for changing password */
|
||||
ChangePasswordRequest: {
|
||||
/** @description Current password for verification */
|
||||
currentPassword: string;
|
||||
/** @description New password to set */
|
||||
newPassword: string;
|
||||
};
|
||||
/** @description Request body for admin password reset */
|
||||
ResetPasswordRequest: {
|
||||
/** @description New password to set for the user */
|
||||
newPassword: string;
|
||||
};
|
||||
/** @description Request body for creating a new user */
|
||||
CreateUserRequest: {
|
||||
/** @description Unique username */
|
||||
username: string;
|
||||
/** @description User's password */
|
||||
password: string;
|
||||
role: components["schemas"]["UserRole"];
|
||||
};
|
||||
/** @description Request body for updating a user */
|
||||
UpdateUserRequest: {
|
||||
/** @description New username (must be unique) */
|
||||
username?: string;
|
||||
role?: components["schemas"]["UserRole"];
|
||||
};
|
||||
/** @description User information */
|
||||
User: {
|
||||
/** @description Unique user identifier */
|
||||
id: string;
|
||||
/** @description User's username */
|
||||
username: string;
|
||||
role: components["schemas"]["UserRole"];
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Account creation timestamp
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Last update timestamp
|
||||
*/
|
||||
updatedAt: string;
|
||||
};
|
||||
/** @description Response containing user information */
|
||||
UserResponse: {
|
||||
user: components["schemas"]["User"];
|
||||
};
|
||||
/** @description Response containing list of users */
|
||||
UsersListResponse: {
|
||||
users: components["schemas"]["User"][];
|
||||
};
|
||||
/** @description Generic success response */
|
||||
SuccessResponse: {
|
||||
/** @description Success message */
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: {
|
||||
/** @description page number of items to fetch (default is 1) */
|
||||
Page: number;
|
||||
/** @description unique identifier of the user */
|
||||
UserId: string;
|
||||
/** @description number of items per page (default is 30, max is 100) */
|
||||
PerPage: number;
|
||||
/** @description the name of the DAG file */
|
||||
@ -1370,6 +1584,522 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
login: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["LoginRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Authentication successful */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["LoginResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid credentials */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Unexpected error */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getCurrentUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Current user information */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Not authenticated */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Unexpected error */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
changePassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChangePasswordRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Password changed successfully */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SuccessResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid request (e.g., weak password) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not authenticated or wrong current password */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Unexpected error */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listUsers: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description List of users */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UsersListResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Not authenticated */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden - requires admin role */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Unexpected error */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
createUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateUserRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description User created successfully */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid request (e.g., weak password, invalid role) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not authenticated */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden - requires admin role */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict - username already exists */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Unexpected error */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description unique identifier of the user */
|
||||
userId: components["parameters"]["UserId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description User details */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Not authenticated */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden - requires admin role */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description User not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Unexpected error */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description unique identifier of the user */
|
||||
userId: components["parameters"]["UserId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description User deleted successfully */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Not authenticated */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden - requires admin role or cannot delete self */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description User not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Unexpected error */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
updateUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description unique identifier of the user */
|
||||
userId: components["parameters"]["UserId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdateUserRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description User updated successfully */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not authenticated */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden - requires admin role */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description User not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict - username already exists */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Unexpected error */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
resetUserPassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description unique identifier of the user */
|
||||
userId: components["parameters"]["UserId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ResetPasswordRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Password reset successfully */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SuccessResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid request (e.g., weak password) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not authenticated */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden - requires admin role */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description User not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Unexpected error */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getWorkers: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@ -3329,3 +4059,9 @@ export enum QueueType {
|
||||
global = "global",
|
||||
dag_based = "dag-based"
|
||||
}
|
||||
export enum UserRole {
|
||||
admin = "admin",
|
||||
manager = "manager",
|
||||
operator = "operator",
|
||||
viewer = "viewer"
|
||||
}
|
||||
|
||||
187
ui/src/components/ChangePasswordModal.tsx
Normal file
187
ui/src/components/ChangePasswordModal.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
import { useState } from 'react';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
type ChangePasswordModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a modal dialog that lets the current user change their password.
|
||||
*
|
||||
* Validates input (matching confirmation and minimum length), submits the change to the configured API, and displays error or success state. The modal resets its form when closed.
|
||||
*
|
||||
* @param open - Whether the modal is visible.
|
||||
* @param onClose - Callback invoked when the modal is closed.
|
||||
* @returns The Change Password modal React element.
|
||||
*/
|
||||
export function ChangePasswordModal({ open, onClose }: ChangePasswordModalProps) {
|
||||
const config = useConfig();
|
||||
const { token } = useAuth();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.apiURL}/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
// Check for nested error message in API response format
|
||||
const message = data.message || data.error?.message || 'Failed to change password';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to change password');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change Password</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 text-sm text-green-600 dark:text-green-400 bg-green-500/10 rounded-md">
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Password changed successfully!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="current-password" className="text-sm">
|
||||
Current Password
|
||||
</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-password" className="text-sm">
|
||||
New Password
|
||||
</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirm-password" className="text-sm">
|
||||
Confirm New Password
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="h-8"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || success}
|
||||
className="h-8"
|
||||
>
|
||||
{isLoading ? 'Changing...' : 'Change Password'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
45
ui/src/components/ProtectedRoute.tsx
Normal file
45
ui/src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth, hasRole } from '@/contexts/AuthContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
|
||||
type ProtectedRouteProps = {
|
||||
children: ReactNode;
|
||||
requiredRole?: 'admin' | 'manager' | 'operator' | 'viewer';
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders `children` only when built-in authentication and optional role checks permit access; otherwise performs the appropriate redirect or renders nothing while auth state is loading.
|
||||
*
|
||||
* If `config.authMode` is not `'builtin'`, access is allowed and `children` are rendered. While auth state is loading the component renders `null`. If the user is not authenticated it redirects to `/login` and preserves the current location for post-login navigation. If a `requiredRole` is provided and the authenticated user lacks that role it redirects to `/`.
|
||||
*
|
||||
* @param requiredRole - Optional role required to access the route; one of `'admin' | 'manager' | 'operator' | 'viewer'`.
|
||||
* @returns The `children` element when access is allowed, `null` while auth state is loading, or a `Navigate` element that redirects the user when access is denied.
|
||||
*/
|
||||
export function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
|
||||
const config = useConfig();
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// If auth mode is not builtin, allow access
|
||||
if (config.authMode !== 'builtin') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Show nothing while loading auth state
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Check role requirement if specified
|
||||
if (requiredRole && user && !hasRole(user.role, requiredRole)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
101
ui/src/components/UserMenu.tsx
Normal file
101
ui/src/components/UserMenu.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { User, LogOut, Key, Shield } from 'lucide-react';
|
||||
import { ChangePasswordModal } from './ChangePasswordModal';
|
||||
|
||||
/**
|
||||
* Renders a user dropdown menu with profile info, a change-password action, and sign-out.
|
||||
*
|
||||
* The menu is rendered only when built-in authentication is enabled and a user is authenticated.
|
||||
*
|
||||
* @returns The user menu JSX element when shown, or `null` when authentication is not available.
|
||||
*/
|
||||
export function UserMenu() {
|
||||
const { user, logout, isAuthenticated } = useAuth();
|
||||
const config = useConfig();
|
||||
const navigate = useNavigate();
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
|
||||
// Don't show if auth is not builtin or user is not authenticated
|
||||
if (config.authMode !== 'builtin' || !isAuthenticated || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'bg-red-500/20 text-red-600 dark:text-red-400';
|
||||
case 'manager':
|
||||
return 'bg-blue-500/20 text-blue-600 dark:text-blue-400';
|
||||
case 'operator':
|
||||
return 'bg-green-500/20 text-green-600 dark:text-green-400';
|
||||
case 'viewer':
|
||||
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-current hover:bg-accent/10"
|
||||
>
|
||||
<User className="h-4 w-4 mr-1.5" />
|
||||
<span className="text-sm">{user.username}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium">{user.username}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Shield className="h-3 w-3 text-muted-foreground" />
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded-full capitalize ${getRoleBadgeColor(user.role)}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setShowChangePassword(true)}>
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
Change Password
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-red-600 dark:text-red-400 focus:text-red-600 dark:focus:text-red-400">
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Sign Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ChangePasswordModal
|
||||
open={showChangePassword}
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
197
ui/src/components/ui/dropdown-menu.tsx
Normal file
197
ui/src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="ml-auto h-4 w-4">›</span>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<span>✓</span>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<span>●</span>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
196
ui/src/contexts/AuthContext.tsx
Normal file
196
ui/src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import { useConfig } from './ConfigContext';
|
||||
|
||||
type UserRole = 'admin' | 'manager' | 'operator' | 'viewer';
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
};
|
||||
|
||||
type AuthContextType = {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export const TOKEN_KEY = 'dagu_auth_token';
|
||||
|
||||
// Role hierarchy for permission checking
|
||||
const ROLE_HIERARCHY: Record<UserRole, number> = {
|
||||
admin: 4,
|
||||
manager: 3,
|
||||
operator: 2,
|
||||
viewer: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides authentication state and actions to descendant components.
|
||||
*
|
||||
* Exposes context values for the current user, auth token, authentication status,
|
||||
* loading state, and functions to log in, log out, and refresh the user from the API.
|
||||
* The provider persists the auth token to localStorage and respects the configured
|
||||
* authentication mode when initializing or refreshing user state.
|
||||
*
|
||||
* @param children - React nodes that receive the authentication context
|
||||
* @returns The context provider element that supplies authentication state and actions
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const config = useConfig();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
const storedToken = localStorage.getItem(TOKEN_KEY);
|
||||
if (!storedToken) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.apiURL}/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${storedToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUser(data.user);
|
||||
setToken(storedToken);
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
} catch {
|
||||
logout();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [config.apiURL, logout]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const response = await fetch(`${config.apiURL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || 'Login failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
localStorage.setItem(TOKEN_KEY, data.token);
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
}, [config.apiURL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.authMode === 'builtin') {
|
||||
refreshUser();
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [config.authMode, refreshUser]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
refreshUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the authentication context supplied by the nearest AuthProvider in the React tree.
|
||||
*
|
||||
* @returns The AuthContext value containing `user`, `token`, `isAuthenticated`, `isLoading`, `login`, `logout`, and `refreshUser`.
|
||||
* @throws Error if there is no surrounding AuthProvider
|
||||
*/
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the current user has administrative privileges.
|
||||
*
|
||||
* @returns `true` if the app is using a non-'builtin' auth mode or the authenticated user's role is `'admin'`, `false` otherwise.
|
||||
*/
|
||||
export function useIsAdmin(): boolean {
|
||||
const { user } = useAuth();
|
||||
const config = useConfig();
|
||||
if (config.authMode !== 'builtin') return true;
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the current user is permitted to create or modify DAGs.
|
||||
*
|
||||
* In non-builtin auth mode this follows the `config.permissions.writeDags` flag.
|
||||
* In builtin auth mode the user must exist and have role `manager` or `admin`.
|
||||
*
|
||||
* @returns `true` if writing DAGs is permitted in the current context, `false` otherwise.
|
||||
*/
|
||||
export function useCanWrite(): boolean {
|
||||
const { user } = useAuth();
|
||||
const config = useConfig();
|
||||
if (config.authMode !== 'builtin') return config.permissions.writeDags;
|
||||
if (!user) return false;
|
||||
return ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY['manager'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the current user is permitted to execute (run) DAGs.
|
||||
*
|
||||
* In non-builtin auth mode this reflects `config.permissions.runDags`; in builtin mode this requires the user's role to be `operator` or higher.
|
||||
*
|
||||
* @returns `true` if execution is permitted for the current context, `false` otherwise.
|
||||
*/
|
||||
export function useCanExecute(): boolean {
|
||||
const { user } = useAuth();
|
||||
const config = useConfig();
|
||||
if (config.authMode !== 'builtin') return config.permissions.runDags;
|
||||
if (!user) return false;
|
||||
return ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY['operator'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a user's role meets or exceeds a required role in the role hierarchy.
|
||||
*
|
||||
* @param userRole - The role held by the user
|
||||
* @param requiredRole - The minimum role required
|
||||
* @returns `true` if `userRole` has at least the permissions of `requiredRole`, `false` otherwise.
|
||||
*/
|
||||
export function hasRole(userRole: UserRole, requiredRole: UserRole): boolean {
|
||||
return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole];
|
||||
}
|
||||
@ -13,6 +13,8 @@ export type PathsConfig = {
|
||||
configFileUsed: string;
|
||||
};
|
||||
|
||||
export type AuthMode = 'none' | 'builtin' | 'oidc' | '';
|
||||
|
||||
export type Config = {
|
||||
apiURL: string;
|
||||
basePath: string;
|
||||
@ -23,6 +25,7 @@ export type Config = {
|
||||
version: string;
|
||||
maxDashboardPageLimit: number;
|
||||
remoteNodes: string;
|
||||
authMode: AuthMode;
|
||||
permissions: {
|
||||
writeDags: boolean;
|
||||
runDags: boolean;
|
||||
|
||||
@ -73,6 +73,7 @@ const ResourceChart: React.FC<ResourceChartProps> = ({
|
||||
|
||||
const lastPoint = formattedData[formattedData.length - 1];
|
||||
const currentValue = lastPoint ? lastPoint.value : 0;
|
||||
const gradientId = `gradient-${title.replace(/\s+/g, '-')}`;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@ -96,15 +97,9 @@ const ResourceChart: React.FC<ResourceChartProps> = ({
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`color-${title}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
@ -127,8 +122,8 @@ const ResourceChart: React.FC<ResourceChartProps> = ({
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
fillOpacity={1}
|
||||
fill={`url(#color-${title})`}
|
||||
strokeWidth={2}
|
||||
fill={`url(#${gradientId})`}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import createClient from 'openapi-fetch';
|
||||
import createClient, { Middleware } from 'openapi-fetch';
|
||||
import {
|
||||
createQueryHook,
|
||||
createImmutableHook,
|
||||
@ -8,9 +8,21 @@ import {
|
||||
import { isMatch } from 'lodash-es';
|
||||
import type { paths } from '../api/v2/schema';
|
||||
|
||||
const authMiddleware: Middleware = {
|
||||
async onRequest({ request }) {
|
||||
const token = localStorage.getItem('dagu_auth_token');
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return request;
|
||||
},
|
||||
};
|
||||
|
||||
const client = createClient<paths>({
|
||||
baseUrl: getConfig().apiURL,
|
||||
});
|
||||
client.use(authMiddleware);
|
||||
|
||||
const prefix = '/';
|
||||
|
||||
export const useQuery = createQueryHook(client, prefix);
|
||||
|
||||
@ -9,10 +9,20 @@ import { cn } from '@/lib/utils';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { AppBarContext } from '../contexts/AppBarContext';
|
||||
import { useConfig } from '../contexts/ConfigContext';
|
||||
import { mainListItems as MainListItems } from '../menu';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import { UserMenu } from '@/components/UserMenu';
|
||||
|
||||
// Utility: Get contrast color (black or white) for a given background color (hex, rgb, or named)
|
||||
/**
|
||||
* Choose a readable foreground color (black or white) that contrasts with the given background color.
|
||||
*
|
||||
* Accepts CSS color inputs such as hex (#rgb or #rrggbb), named colors, or rgb/rgba strings. When the input
|
||||
* cannot be parsed or when executed outside a browser environment, a conservative fallback is used.
|
||||
*
|
||||
* @param input - Background color value to evaluate (hex string, named color, or rgb/rgba). If omitted or empty, black is assumed.
|
||||
* @returns `'#000'` for a dark foreground (black) when the background is light, `'#fff'` for a light foreground (white) when the background is dark.
|
||||
*/
|
||||
function getContrastColor(input?: string): string {
|
||||
if (!input) return '#000'; // Default to black if undefined or empty
|
||||
|
||||
@ -75,8 +85,19 @@ type LayoutProps = {
|
||||
children?: React.ReactElement | React.ReactElement[];
|
||||
};
|
||||
|
||||
// Main Content component including Sidebar and AppBar logic
|
||||
/**
|
||||
* Render the application's main layout including a responsive sidebar, app bar, and scrollable content area.
|
||||
*
|
||||
* The header uses `navbarColor` when provided and computes an appropriate contrast color for its text.
|
||||
* The desktop sidebar expansion state is persisted to `localStorage` under `sidebarExpanded`.
|
||||
*
|
||||
* @param title - Text displayed in the app bar as the primary title
|
||||
* @param navbarColor - Optional CSS color used as the app bar background; contrast text color is derived automatically
|
||||
* @param children - Content rendered in the main scrollable area of the layout
|
||||
* @returns The JSX element for the full layout (sidebar, app bar, and main content)
|
||||
*/
|
||||
function Content({ title, navbarColor, children }: LayoutProps) {
|
||||
const config = useConfig();
|
||||
const [scrolled, setScrolled] = React.useState(false);
|
||||
// Sidebar state with localStorage persistence
|
||||
const [isSidebarExpanded, setIsSidebarExpanded] = React.useState(() => {
|
||||
@ -227,7 +248,9 @@ function Content({ title, navbarColor, children }: LayoutProps) {
|
||||
<div className="flex items-center space-x-2">
|
||||
<AppBarContext.Consumer>
|
||||
{(context) => {
|
||||
// Hide remote node selector when builtin auth is enabled (auth is local-only)
|
||||
if (
|
||||
config.authMode === 'builtin' ||
|
||||
!context.remoteNodes ||
|
||||
context.remoteNodes.length === 0
|
||||
) {
|
||||
@ -257,6 +280,7 @@ function Content({ title, navbarColor, children }: LayoutProps) {
|
||||
}}
|
||||
</AppBarContext.Consumer>
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@ -304,4 +328,4 @@ const NavBarTitleText = ({
|
||||
// Default export Layout component
|
||||
export default function Layout({ children, ...props }: LayoutProps) {
|
||||
return <Content {...props}>{children}</Content>;
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,32 @@
|
||||
/**
|
||||
* Fetches JSON from the configured API base URL and returns the parsed response body.
|
||||
*
|
||||
* This function appends `input` to `getConfig().apiURL`, merges any provided `init.headers`
|
||||
* with `Accept: application/json`, and, if present, adds an `Authorization: Bearer <token>`
|
||||
* header using the token stored in localStorage under `dagu_auth_token`.
|
||||
*
|
||||
* @param input - The request URL or RequestInfo which will be appended to the API base URL
|
||||
* @param init - Optional fetch init options; provided headers are merged with the function's headers
|
||||
* @returns The parsed response body as JSON
|
||||
* @throws FetchError when the response has a non-OK status; the error includes the original `Response` and the parsed response body
|
||||
*/
|
||||
export default async function fetchJson<JSON = unknown>(
|
||||
input: RequestInfo,
|
||||
init?: RequestInit
|
||||
): Promise<JSON> {
|
||||
const token = localStorage.getItem('dagu_auth_token');
|
||||
const headers: HeadersInit = {
|
||||
...(init?.headers || {}),
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${getConfig().apiURL}${input}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers || {}),
|
||||
Accept: 'application/json',
|
||||
},
|
||||
headers,
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
@ -48,4 +67,4 @@ export class FetchError extends Error {
|
||||
this.response = response;
|
||||
this.data = data ?? { message: message };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import logoDark from '@/assets/images/logo_dark.png';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { useIsAdmin } from '@/contexts/AuthContext';
|
||||
import { cn } from '@/lib/utils'; // Assuming cn utility is available
|
||||
import {
|
||||
Activity,
|
||||
@ -10,6 +11,7 @@ import {
|
||||
PanelLeft,
|
||||
Search,
|
||||
Server,
|
||||
Users,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
@ -65,6 +67,7 @@ export const mainListItems = React.forwardRef<
|
||||
>(({ isOpen = false, onNavItemClick, onToggle }, ref) => {
|
||||
// Get version from config at the top level of the component
|
||||
const config = useConfig();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
// State for hover
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
@ -216,6 +219,15 @@ export const mainListItems = React.forwardRef<
|
||||
isOpen={isOpen}
|
||||
onClick={onNavItemClick}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<NavItem
|
||||
to="/users"
|
||||
text="User Management"
|
||||
icon={<Users size={18} />}
|
||||
isOpen={isOpen}
|
||||
onClick={onNavItemClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
{/* Discord Community link */}
|
||||
|
||||
107
ui/src/pages/login.tsx
Normal file
107
ui/src/pages/login.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Renders the login page UI and handles authentication flow.
|
||||
*
|
||||
* The component shows username and password fields, displays an error banner when login fails,
|
||||
* and redirects the user to the intended destination after successful authentication.
|
||||
*
|
||||
* @returns The rendered login page React element.
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
const config = useConfig();
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const from = (location.state as { from?: Location })?.from?.pathname || '/';
|
||||
|
||||
// Redirect if already authenticated - use useEffect to avoid render-phase side effects
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate, from]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate(from, { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="w-full max-w-sm p-6 space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-bold">{config.title || 'Dagu'}</h1>
|
||||
<p className="text-sm text-muted-foreground">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username" className="text-sm">
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password" className="text-sm">
|
||||
Password
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full h-9" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
ui/src/pages/users/ResetPasswordModal.tsx
Normal file
174
ui/src/pages/users/ResetPasswordModal.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { useState } from 'react';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { components } from '@/api/v2/schema';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
type User = components['schemas']['User'];
|
||||
|
||||
type ResetPasswordModalProps = {
|
||||
open: boolean;
|
||||
user?: User;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a modal dialog allowing an administrator to set a new password for a user.
|
||||
*
|
||||
* Validates that the new and confirm passwords match and are at least 8 characters, sends
|
||||
* a PUT request to update the user's password using the auth token from localStorage, and
|
||||
* shows inline error or success feedback. The dialog resets its form state when closed.
|
||||
*
|
||||
* @param open - Whether the dialog is visible
|
||||
* @param user - The target user whose password will be reset
|
||||
* @param onClose - Callback invoked when the dialog is closed
|
||||
* @returns The Reset Password modal component
|
||||
*/
|
||||
export function ResetPasswordModal({ open, user, onClose }: ResetPasswordModalProps) {
|
||||
const config = useConfig();
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!user) return;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('dagu_auth_token');
|
||||
const response = await fetch(`${config.apiURL}/users/${user.id}/password`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ newPassword }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || 'Failed to reset password');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to reset password');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset Password for {user?.username}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 text-sm text-green-600 dark:text-green-400 bg-green-500/10 rounded-md">
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Password reset successfully!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-password" className="text-sm">
|
||||
New Password
|
||||
</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="h-9"
|
||||
placeholder="Minimum 8 characters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirm-password" className="text-sm">
|
||||
Confirm Password
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="h-8"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || success}
|
||||
className="h-8"
|
||||
>
|
||||
{isLoading ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
205
ui/src/pages/users/UserFormModal.tsx
Normal file
205
ui/src/pages/users/UserFormModal.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { components } from '@/api/v2/schema';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
type User = components['schemas']['User'];
|
||||
|
||||
type UserFormModalProps = {
|
||||
open: boolean;
|
||||
user?: User;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'admin', label: 'Admin', description: 'Full access including user management' },
|
||||
{ value: 'manager', label: 'Manager', description: 'DAG create/edit/delete and execution' },
|
||||
{ value: 'operator', label: 'Operator', description: 'DAG execution only' },
|
||||
{ value: 'viewer', label: 'Viewer', description: 'Read-only access' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Render a modal dialog that provides a form to create a new user or edit an existing one.
|
||||
*
|
||||
* @param props.open - Whether the modal is open.
|
||||
* @param props.user - Existing user to edit; when undefined the form operates in create mode.
|
||||
* @param props.onClose - Callback invoked when the modal is closed.
|
||||
* @param props.onSuccess - Callback invoked after a successful create or update operation.
|
||||
* @returns The modal JSX element containing the user form.
|
||||
*/
|
||||
export function UserFormModal({ open, user, onClose, onSuccess }: UserFormModalProps) {
|
||||
const config = useConfig();
|
||||
const isEditing = !!user;
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<string>('viewer');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setUsername(user.username);
|
||||
setRole(user.role);
|
||||
setPassword('');
|
||||
} else {
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setRole('viewer');
|
||||
}
|
||||
setError(null);
|
||||
}, [user, open]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!isEditing && password.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('dagu_auth_token');
|
||||
|
||||
if (isEditing) {
|
||||
// Update user
|
||||
const response = await fetch(`${config.apiURL}/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ username, role }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || 'Failed to update user');
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
const response = await fetch(`${config.apiURL}/users`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ username, password, role }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || 'Failed to create user');
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Operation failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? 'Edit User' : 'Create User'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username" className="text-sm">
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password" className="text-sm">
|
||||
Password
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="h-9"
|
||||
placeholder="Minimum 8 characters"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="role" className="text-sm">
|
||||
Role
|
||||
</Label>
|
||||
<Select value={role} onValueChange={setRole}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLES.map((r) => (
|
||||
<SelectItem key={r.value} value={r.value}>
|
||||
<div className="flex flex-col">
|
||||
<span>{r.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{r.description}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose} className="h-8">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading} className="h-8">
|
||||
{isLoading ? 'Saving...' : isEditing ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
267
ui/src/pages/users/index.tsx
Normal file
267
ui/src/pages/users/index.tsx
Normal file
@ -0,0 +1,267 @@
|
||||
import { useState, useEffect, useCallback, useContext } from 'react';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { useAuth, TOKEN_KEY } from '@/contexts/AuthContext';
|
||||
import { AppBarContext } from '@/contexts/AppBarContext';
|
||||
import { components } from '@/api/v2/schema';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { UserPlus, MoreHorizontal, Pencil, Trash2, Key, Shield } from 'lucide-react';
|
||||
import { UserFormModal } from './UserFormModal';
|
||||
import { ResetPasswordModal } from './ResetPasswordModal';
|
||||
import ConfirmModal from '@/ui/ConfirmModal';
|
||||
import dayjs from '@/lib/dayjs';
|
||||
|
||||
type User = components['schemas']['User'];
|
||||
|
||||
/**
|
||||
* Render the Users management page with a table of accounts and controls for creating, editing, resetting passwords, and deleting users.
|
||||
*
|
||||
* This component sets the application bar title to "User Management", fetches the user list from the configured API using a stored token, and manages loading and error states. It highlights the current user, formats created/updated timestamps, and exposes per-user actions that open the appropriate modals (create, edit, reset password, delete). Deletion performs an API DELETE request and refreshes the list on success.
|
||||
*
|
||||
* @returns The Users page component as a JSX.Element
|
||||
*/
|
||||
export default function UsersPage() {
|
||||
const config = useConfig();
|
||||
const { user: currentUser } = useAuth();
|
||||
const appBarContext = useContext(AppBarContext);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Modal states
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(null);
|
||||
const [deletingUser, setDeletingUser] = useState<User | null>(null);
|
||||
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
appBarContext.setTitle('User Management');
|
||||
}, [appBarContext]);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
const response = await fetch(`${config.apiURL}/users`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch users');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setUsers(data.users || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load users');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [config.apiURL]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!deletingUser) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
const response = await fetch(`${config.apiURL}/users/${deletingUser.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || 'Failed to delete user');
|
||||
}
|
||||
|
||||
setError(null); // Clear any previous error on success
|
||||
setDeletingUser(null);
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete user');
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'bg-red-500/20 text-red-600 dark:text-red-400';
|
||||
case 'manager':
|
||||
return 'bg-blue-500/20 text-blue-600 dark:text-blue-400';
|
||||
case 'operator':
|
||||
return 'bg-green-500/20 text-green-600 dark:text-green-400';
|
||||
case 'viewer':
|
||||
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Users</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage user accounts and their roles
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)} size="sm" className="h-8">
|
||||
<UserPlus className="h-4 w-4 mr-1.5" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">Username</TableHead>
|
||||
<TableHead className="w-[120px]">Role</TableHead>
|
||||
<TableHead className="w-[180px]">Created</TableHead>
|
||||
<TableHead className="w-[180px]">Updated</TableHead>
|
||||
<TableHead className="w-[80px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
Loading users...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
No users found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.username}
|
||||
{user.id === currentUser?.id && (
|
||||
<span className="text-xs text-muted-foreground">(you)</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Shield className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded-full capitalize ${getRoleBadgeColor(user.role)}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{dayjs(user.createdAt).format('MMM D, YYYY HH:mm')}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{dayjs(user.updatedAt).format('MMM D, YYYY HH:mm')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditingUser(user)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setResetPasswordUser(user)}>
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
Reset Password
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeletingUser(user)}
|
||||
className="text-destructive"
|
||||
disabled={user.id === currentUser?.id}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Create User Modal */}
|
||||
<UserFormModal
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreateModal(false);
|
||||
fetchUsers();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Edit User Modal */}
|
||||
<UserFormModal
|
||||
open={!!editingUser}
|
||||
user={editingUser || undefined}
|
||||
onClose={() => setEditingUser(null)}
|
||||
onSuccess={() => {
|
||||
setEditingUser(null);
|
||||
fetchUsers();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
<ResetPasswordModal
|
||||
open={!!resetPasswordUser}
|
||||
user={resetPasswordUser || undefined}
|
||||
onClose={() => setResetPasswordUser(null)}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmModal
|
||||
title="Delete User"
|
||||
buttonText="Delete"
|
||||
visible={!!deletingUser}
|
||||
dismissModal={() => setDeletingUser(null)}
|
||||
onSubmit={handleDeleteUser}
|
||||
>
|
||||
<p>Are you sure you want to delete user "{deletingUser?.username}"? This action cannot be undone.</p>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user