From 699c965c3a98eb3290e1b4de4605de8ad266de64 Mon Sep 17 00:00:00 2001 From: NightYu Date: Fri, 5 Dec 2025 09:33:12 +0800 Subject: [PATCH 1/3] feat(users): implement user status and expiration management - Added `is_enabled` and `expires_at` fields to the users table to manage user activation status and expiration dates. - Updated user-related actions and forms to handle the new fields, including enabling/disabling users and renewing their expiration dates. - Enhanced user interface components to display user status and expiration information. - Implemented backend logic to mark users as expired and handle authentication based on user status. This update improves user management capabilities and enhances the overall user experience in the dashboard. --- drizzle/0028_abnormal_pretty_boy.sql | 3 + drizzle/meta/0028_snapshot.json | 1816 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/en/dashboard.json | 35 + messages/ja/dashboard.json | 20 + messages/ru/dashboard.json | 20 + messages/zh-CN/dashboard.json | 35 + messages/zh-TW/dashboard.json | 20 + src/actions/users.ts | 115 ++ .../_components/user/add-user-dialog.tsx | 2 +- .../_components/user/forms/user-form.tsx | 36 + .../_components/user/key-list-header.tsx | 45 +- .../_components/user/user-actions.tsx | 2 +- .../_components/user/user-key-manager.tsx | 4 + .../dashboard/_components/user/user-list.tsx | 326 ++- src/app/v1/_lib/proxy/auth-guard.ts | 30 + src/drizzle/schema.ts | 11 +- src/lib/auth.ts | 2 + src/lib/validation/schemas.ts | 13 + src/repository/_shared/transformers.ts | 2 + src/repository/key.ts | 4 + src/repository/user.ts | 28 + src/types/user.ts | 12 + 23 files changed, 2560 insertions(+), 28 deletions(-) create mode 100644 drizzle/0028_abnormal_pretty_boy.sql create mode 100644 drizzle/meta/0028_snapshot.json diff --git a/drizzle/0028_abnormal_pretty_boy.sql b/drizzle/0028_abnormal_pretty_boy.sql new file mode 100644 index 000000000..f233726c9 --- /dev/null +++ b/drizzle/0028_abnormal_pretty_boy.sql @@ -0,0 +1,3 @@ +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "is_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "expires_at" timestamp with time zone;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_users_enabled_expires_at" ON "users" USING btree ("is_enabled","expires_at") WHERE "deleted_at" IS NULL; \ No newline at end of file diff --git a/drizzle/meta/0028_snapshot.json b/drizzle/meta/0028_snapshot.json new file mode 100644 index 000000000..da5f64c94 --- /dev/null +++ b/drizzle/meta/0028_snapshot.json @@ -0,0 +1,1816 @@ +{ + "id": "7a12c606-63fc-4516-844d-38a6bce3058c", + "prevId": "76e25a16-29c5-4322-a024-c2cba979a313", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false, + "default": "'100.00'" + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 82398965e..ba51c86a2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1764780918575, "tag": "0027_happy_sharon_carter", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1764865332897, + "tag": "0028_abnormal_pretty_boy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index fd36f7a07..2033d1c39 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -480,6 +480,32 @@ "badge": "{count} Keys", "activeKeys": "Active Keys", "totalKeys": "Total Keys", + "expiresAt": "Expiration", + "status": { + "active": "Active", + "expiringSoon": "Expiring Soon", + "expired": "Expired", + "disabled": "Disabled" + }, + "actions": { + "renew": "Renew", + "renew30d": "Renew for 30 days", + "renew90d": "Renew for 90 days", + "renew1y": "Renew for 1 year", + "renewCustom": "Custom...", + "customRenewTitle": "Custom Renewal", + "customRenewDescription": "Set a new expiration date for user {userName}", + "expirationDate": "Expiration Date", + "enableOnRenew": "Enable user on renewal", + "cancel": "Cancel", + "confirm": "Confirm", + "customPrompt": "Enter a new expiry date (YYYY-MM-DD). Leave empty to cancel.", + "invalidDate": "Please enter a valid date", + "enable": "Enable", + "disable": "Disable", + "success": "Operation succeeded", + "failed": "Operation failed, please try again" + }, "emptyState": { "title": "No Users", "description": "You haven't created any users yet. Users are the foundation for managing API keys and usage quotas. Create your first user to get started!", @@ -674,6 +700,15 @@ "label": "Concurrent Sessions Limit", "placeholder": "0 means unlimited", "description": "Number of simultaneous conversations" + }, + "isEnabled": { + "label": "Enable User", + "description": "Disabled users cannot access the API" + }, + "expiresAt": { + "label": "Expiration Date", + "placeholder": "Leave empty for never expires", + "description": "User will be automatically disabled after expiration" } }, "deleteKeyConfirm": { diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 987c7f00d..1d4259e33 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -479,6 +479,26 @@ "badge": "{count} 個のキー", "activeKeys": "アクティブキー", "totalKeys": "総キー数", + "expiresAt": "有効期限", + "status": { + "active": "有効", + "expiringSoon": "まもなく期限切れ", + "expired": "期限切れ", + "disabled": "無効" + }, + "actions": { + "renew": "更新", + "renew30d": "30日間更新", + "renew90d": "90日間更新", + "renew1y": "1年間更新", + "renewCustom": "カスタム...", + "customPrompt": "新しい有効期限を入力してください(YYYY-MM-DD)。キャンセルするには空のままにしてください。", + "invalidDate": "有効な日付を入力してください", + "enable": "有効化", + "disable": "無効化", + "success": "操作に成功しました", + "failed": "操作に失敗しました。後でもう一度お試しください" + }, "emptyState": { "title": "ユーザーがいません", "description": "まだユーザーを作成していません。ユーザーは API キーと使用制限を管理するための基本単位です。最初のユーザーを作成して始めましょう!", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index a9bbf73bb..fc0e73bdf 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -479,6 +479,26 @@ "badge": "{count} ключей", "activeKeys": "Активные ключи", "totalKeys": "Всего ключей", + "expiresAt": "Срок действия", + "status": { + "active": "Активен", + "expiringSoon": "Скоро истечет", + "expired": "Истек", + "disabled": "Отключен" + }, + "actions": { + "renew": "Продлить", + "renew30d": "Продлить на 30 дней", + "renew90d": "Продлить на 90 дней", + "renew1y": "Продлить на 1 год", + "renewCustom": "Настроить...", + "customPrompt": "Введите новую дату истечения (ГГГГ-ММ-ДД). Оставьте пустым для отмены.", + "invalidDate": "Пожалуйста, введите действительную дату", + "enable": "Включить", + "disable": "Отключить", + "success": "Операция выполнена успешно", + "failed": "Операция не удалась, попробуйте еще раз" + }, "emptyState": { "title": "Нет пользователей", "description": "Вы еще не создали ни одного пользователя. Пользователи являются основой для управления API-ключами и квотами использования. Создайте первого пользователя, чтобы начать!", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index d384e18dd..6e82eaec4 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -480,6 +480,32 @@ "badge": "{count} 个 Key", "activeKeys": "活跃密钥", "totalKeys": "总密钥", + "expiresAt": "过期时间", + "status": { + "active": "已启用", + "expiringSoon": "即将过期", + "expired": "已过期", + "disabled": "已禁用" + }, + "actions": { + "renew": "续期", + "renew30d": "续期 30 天", + "renew90d": "续期 90 天", + "renew1y": "续期 1 年", + "renewCustom": "自定义...", + "customRenewTitle": "自定义续期时间", + "customRenewDescription": "为用户 {userName} 设置新的过期时间", + "expirationDate": "过期日期", + "enableOnRenew": "同时启用用户", + "cancel": "取消", + "confirm": "确认", + "customPrompt": "请输入新的过期日期(YYYY-MM-DD),取消请留空", + "invalidDate": "请输入有效日期", + "enable": "启用", + "disable": "禁用", + "success": "操作成功", + "failed": "操作失败,请稍后再试" + }, "emptyState": { "title": "暂无用户", "description": "您还没有创建任何用户。用户是管理 API 密钥和使用限额的基础单位,创建第一个用户开始使用吧!", @@ -674,6 +700,15 @@ "label": "并发 Session 上限", "placeholder": "0 表示无限制", "description": "同时运行的对话数量" + }, + "isEnabled": { + "label": "启用用户", + "description": "禁用后用户将无法使用 API" + }, + "expiresAt": { + "label": "过期时间", + "placeholder": "留空表示永不过期", + "description": "用户过期后将自动禁用" } }, "deleteKeyConfirm": { diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 075a47e5b..bfbe99e91 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -480,6 +480,26 @@ "badge": "{count} 個金鑰", "activeKeys": "活躍金鑰", "totalKeys": "總金鑰數", + "expiresAt": "過期時間", + "status": { + "active": "已啟用", + "expiringSoon": "即將過期", + "expired": "已過期", + "disabled": "已停用" + }, + "actions": { + "renew": "續期", + "renew30d": "續期 30 天", + "renew90d": "續期 90 天", + "renew1y": "續期 1 年", + "renewCustom": "自訂...", + "customPrompt": "請輸入新的過期日期(YYYY-MM-DD),取消請留空", + "invalidDate": "請輸入有效日期", + "enable": "啟用", + "disable": "停用", + "success": "操作成功", + "failed": "操作失敗,請稍後再試" + }, "emptyState": { "title": "暫無使用者", "description": "您還沒有建立任何使用者。使用者是管理 API 金鑰和使用限額的基礎單位,建立第一個使用者開始使用吧!", diff --git a/src/actions/users.ts b/src/actions/users.ts index c7e848724..4e4c2995d 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -75,6 +75,8 @@ export async function getUsers(): Promise { limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, + isEnabled: user.isEnabled, + expiresAt: user.expiresAt ?? null, keys: keys.map((key) => { const stats = statisticsMap.get(key.id); // 用户可以查看和复制自己的密钥,管理员可以查看和复制所有密钥 @@ -133,6 +135,8 @@ export async function getUsers(): Promise { limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, + isEnabled: user.isEnabled, + expiresAt: user.expiresAt ?? null, keys: [], }; } @@ -159,6 +163,8 @@ export async function addUser(data: { limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number | null; + isEnabled?: boolean; + expiresAt?: Date | null; }): Promise { try { // Get translations for error messages @@ -211,6 +217,8 @@ export async function addUser(data: { limitMonthlyUsd: validatedData.limitMonthlyUsd ?? undefined, limitTotalUsd: validatedData.limitTotalUsd ?? undefined, limitConcurrentSessions: validatedData.limitConcurrentSessions ?? undefined, + isEnabled: data.isEnabled ?? true, + expiresAt: data.expiresAt ?? null, }); // 为新用户创建默认密钥 @@ -252,6 +260,8 @@ export async function editUser( limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number | null; + isEnabled?: boolean; + expiresAt?: Date | null; } ): Promise { try { @@ -313,6 +323,8 @@ export async function editUser( limitMonthlyUsd: validatedData.limitMonthlyUsd ?? undefined, limitTotalUsd: validatedData.limitTotalUsd ?? undefined, limitConcurrentSessions: validatedData.limitConcurrentSessions ?? undefined, + isEnabled: data.isEnabled, + expiresAt: data.expiresAt, }); revalidatePath("/dashboard"); @@ -421,3 +433,106 @@ export async function getUserLimitUsage(userId: number): Promise< return { ok: false, error: message, errorCode: ERROR_CODES.OPERATION_FAILED }; } } + +/** + * 续期用户(延长过期时间) + */ +export async function renewUser( + userId: number, + data: { + expiresAt: string; // ISO 8601 string to avoid serialization issues + enableUser?: boolean; // 是否同时启用用户 + } +): Promise { + try { + // Get translations for error messages + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + // Parse and validate expiration date + const expiresAt = new Date(data.expiresAt); + if (isNaN(expiresAt.getTime())) { + return { + ok: false, + error: tError("INVALID_FORMAT"), + errorCode: ERROR_CODES.INVALID_FORMAT, + }; + } + + // Update user expiration date and optionally enable user + const updateData: { + expiresAt: Date; + isEnabled?: boolean; + } = { + expiresAt, + }; + + if (data.enableUser === true) { + updateData.isEnabled = true; + } + + await updateUser(userId, updateData); + + revalidatePath("/dashboard"); + return { ok: true }; + } catch (error) { + logger.error("Failed to renew user:", error); + const tError = await getTranslations("errors"); + const message = error instanceof Error ? error.message : tError("UPDATE_USER_FAILED"); + return { + ok: false, + error: message, + errorCode: ERROR_CODES.UPDATE_FAILED, + }; + } +} + +/** + * 切换用户启用/禁用状态 + */ +export async function toggleUserEnabled(userId: number, enabled: boolean): Promise { + try { + // Get translations for error messages + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + // Prevent disabling self + if (session.user.id === userId && !enabled) { + return { + ok: false, + error: tError("CANNOT_DISABLE_SELF"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + await updateUser(userId, { isEnabled: enabled }); + + revalidatePath("/dashboard"); + return { ok: true }; + } catch (error) { + logger.error("Failed to toggle user enabled status:", error); + const tError = await getTranslations("errors"); + const message = error instanceof Error ? error.message : tError("UPDATE_USER_FAILED"); + return { + ok: false, + error: message, + errorCode: ERROR_CODES.UPDATE_FAILED, + }; + } +} diff --git a/src/app/[locale]/dashboard/_components/user/add-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/add-user-dialog.tsx index 3d7526352..c91c1fa74 100644 --- a/src/app/[locale]/dashboard/_components/user/add-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/add-user-dialog.tsx @@ -34,7 +34,7 @@ export function AddUserDialog({ {t("addUser")} - + setOpen(false)} currentUser={currentUser} /> diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx index 4c440f7c9..28e6a11db 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx @@ -7,6 +7,7 @@ import { getAvailableProviderGroups } from "@/actions/providers"; import { addUser, editUser } from "@/actions/users"; import { ArrayTagInputField, TagInputField, TextField } from "@/components/form/form-field"; import { DialogFormLayout, FormGrid } from "@/components/form/form-layout"; +import { Switch } from "@/components/ui/switch"; import { USER_DEFAULTS, USER_LIMITS } from "@/lib/constants/user.constants"; import { useZodForm } from "@/lib/hooks/use-zod-form"; import { getErrorMessage } from "@/lib/utils/error-messages"; @@ -27,6 +28,8 @@ interface UserFormProps { limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number | null; + isEnabled?: boolean; + expiresAt?: Date | null; }; onSuccess?: () => void; currentUser?: { @@ -70,6 +73,8 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { limitMonthlyUsd: user?.limitMonthlyUsd ?? null, limitTotalUsd: user?.limitTotalUsd ?? null, limitConcurrentSessions: user?.limitConcurrentSessions ?? null, + isEnabled: user?.isEnabled ?? true, + expiresAt: user?.expiresAt ? user.expiresAt.toISOString().split("T")[0] : "", }, onSubmit: async (data) => { startTransition(async () => { @@ -88,6 +93,8 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { limitMonthlyUsd: data.limitMonthlyUsd, limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, + isEnabled: data.isEnabled, + expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, }); } else { res = await addUser({ @@ -102,6 +109,8 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { limitMonthlyUsd: data.limitMonthlyUsd, limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, + isEnabled: data.isEnabled, + expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, }); } @@ -277,6 +286,33 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { /> )} + + {/* Admin-only user status fields */} + {isAdmin && ( + <> +
+
+ +

{tForm("isEnabled.description")}

+
+ form.setValue("isEnabled", checked)} + /> +
+ + + + )} ); } diff --git a/src/app/[locale]/dashboard/_components/user/key-list-header.tsx b/src/app/[locale]/dashboard/_components/user/key-list-header.tsx index a5c491b5b..fe0ccf261 100644 --- a/src/app/[locale]/dashboard/_components/user/key-list-header.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-list-header.tsx @@ -1,10 +1,11 @@ "use client"; import { useQuery } from "@tanstack/react-query"; import { CheckCircle, Copy, Eye, EyeOff, ListPlus } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useEffect, useMemo, useState } from "react"; import { getProxyStatus } from "@/actions/proxy-status"; import { FormErrorBoundary } from "@/components/form-error-boundary"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -17,6 +18,7 @@ import { } from "@/components/ui/dialog"; import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; +import { formatDate, formatDateDistance } from "@/lib/utils/date-format"; import type { ProxyStatusResponse } from "@/types/proxy-status"; import type { User, UserDisplay } from "@/types/user"; import { AddKeyForm } from "./forms/add-key-form"; @@ -95,6 +97,8 @@ export function KeyListHeader({ const [keyVisible, setKeyVisible] = useState(false); const [clipboardAvailable, setClipboardAvailable] = useState(false); const t = useTranslations("dashboard.keyListHeader"); + const tUsers = useTranslations("users"); + const locale = useLocale(); // 检测 clipboard 是否可用 useEffect(() => { @@ -106,6 +110,32 @@ export function KeyListHeader({ const formatRelativeTime = useMemo(() => createFormatRelativeTime(t), [t]); + // 获取用户状态和过期信息 + const userStatusInfo = useMemo(() => { + if (!activeUser) return null; + + const now = Date.now(); + const exp = activeUser.expiresAt ? new Date(activeUser.expiresAt).getTime() : null; + + let status: { code: string; badge: string; variant: "default" | "secondary" | "destructive" | "outline" }; + + if (!activeUser.isEnabled) { + status = { code: "disabled", badge: "已禁用", variant: "secondary" }; + } else if (exp && exp <= now) { + status = { code: "expired", badge: "已过期", variant: "destructive" }; + } else if (exp && exp - now <= 72 * 60 * 60 * 1000) { + status = { code: "expiringSoon", badge: "即将过期", variant: "outline" }; + } else { + status = { code: "active", badge: "已启用", variant: "default" }; + } + + const expiryText = activeUser.expiresAt + ? `${formatDateDistance(activeUser.expiresAt, new Date(), locale, { addSuffix: true })} (${formatDate(activeUser.expiresAt, "yyyy-MM-dd", locale)})` + : tUsers("neverExpires"); + + return { status, expiryText }; + }, [activeUser, locale, tUsers]); + const proxyStatusEnabled = Boolean(activeUser); const { data: proxyStatus, @@ -214,6 +244,11 @@ export function KeyListHeader({
{activeUser ? activeUser.name : "-"} + {activeUser && userStatusInfo && ( + + {userStatusInfo.status.badge} + + )} {activeUser && }
@@ -222,6 +257,12 @@ export function KeyListHeader({ {t("todayUsage")} {activeUser ? formatCurrency(totalTodayUsage, currencyCode) : "-"}{" "} / {activeUser ? formatCurrency(activeUser.dailyQuota, currencyCode) : "-"}
+ {activeUser && userStatusInfo && ( +
+ 过期时间: + {userStatusInfo.expiryText} +
+ )} {proxyStatusContent}
@@ -258,6 +299,8 @@ export function KeyListHeader({ limitWeeklyUsd: activeUser.limitWeeklyUsd ?? undefined, limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined, limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined, + isEnabled: activeUser.isEnabled, + expiresAt: activeUser.expiresAt ?? undefined, } : undefined } diff --git a/src/app/[locale]/dashboard/_components/user/user-actions.tsx b/src/app/[locale]/dashboard/_components/user/user-actions.tsx index 54285add3..1913bbeb2 100644 --- a/src/app/[locale]/dashboard/_components/user/user-actions.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-actions.tsx @@ -40,7 +40,7 @@ export function UserActions({ user, currentUser }: UserActionsProps) { - + setOpenEdit(false)} currentUser={currentUser} /> diff --git a/src/app/[locale]/dashboard/_components/user/user-key-manager.tsx b/src/app/[locale]/dashboard/_components/user/user-key-manager.tsx index 24a2cbdf3..1b9d3928f 100644 --- a/src/app/[locale]/dashboard/_components/user/user-key-manager.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-key-manager.tsx @@ -56,6 +56,8 @@ export function UserKeyManager({ users, currentUser, currencyCode = "USD" }: Use limitWeeklyUsd: activeUser.limitWeeklyUsd ?? undefined, limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined, limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined, + isEnabled: activeUser.isEnabled, + expiresAt: activeUser.expiresAt ?? undefined, } : undefined } @@ -106,6 +108,8 @@ export function UserKeyManager({ users, currentUser, currencyCode = "USD" }: Use limitWeeklyUsd: activeUser.limitWeeklyUsd ?? undefined, limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined, limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined, + isEnabled: activeUser.isEnabled, + expiresAt: activeUser.expiresAt ?? undefined, } : undefined } diff --git a/src/app/[locale]/dashboard/_components/user/user-list.tsx b/src/app/[locale]/dashboard/_components/user/user-list.tsx index fe995ffd0..4b427b9dd 100644 --- a/src/app/[locale]/dashboard/_components/user/user-list.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-list.tsx @@ -1,7 +1,31 @@ "use client"; -import { Users } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { addDays } from "date-fns"; +import { Loader2, Users } from "lucide-react"; +import { useLocale, useTranslations } from "next-intl"; +import { useMemo, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { renewUser, toggleUserEnabled } from "@/actions/users"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { ListContainer, ListItem, type ListItemData } from "@/components/ui/list"; +import { Switch } from "@/components/ui/switch"; +import { formatDate, formatDateDistance } from "@/lib/utils/date-format"; import type { User, UserDisplay } from "@/types/user"; import { AddUserDialog } from "./add-user-dialog"; @@ -14,28 +38,161 @@ interface UserListProps { export function UserList({ users, activeUserId, onUserSelect, currentUser }: UserListProps) { const t = useTranslations("dashboard.userList"); + const tUsers = useTranslations("users"); + const locale = useLocale(); + const isAdmin = currentUser?.role === "admin"; + const [pendingUserId, setPendingUserId] = useState(null); + const [isPending, startTransition] = useTransition(); - // 转换数据格式 - const listItems: ListItemData[] = users.map((user) => ({ - id: user.id, - title: user.name, - subtitle: user.note, - badge: { - text: t("badge", { count: user.keys.length }), - variant: "outline" as const, - }, - tags: user.tags, - metadata: [ - { - label: t("activeKeys"), - value: user.keys.filter((k) => k.status === "enabled").length.toString(), - }, - { - label: t("totalKeys"), - value: user.keys.length.toString(), - }, - ], - })); + // 自定义续期对话框状态 + const [customRenewDialog, setCustomRenewDialog] = useState<{ + open: boolean; + user: UserDisplay | null; + }>({ open: false, user: null }); + const [customDate, setCustomDate] = useState(""); + const [enableOnRenew, setEnableOnRenew] = useState(false); + + const EXPIRING_SOON_MS = 72 * 60 * 60 * 1000; // 72 hours + + // Calculate user status based on isEnabled and expiresAt + const getStatusInfo = (user: UserDisplay, now: number) => { + const exp = user.expiresAt ? new Date(user.expiresAt).getTime() : null; + if (!user.isEnabled) { + return { + code: "disabled" as const, + badgeVariant: "secondary" as const, + }; + } + if (exp && exp <= now) { + return { + code: "expired" as const, + badgeVariant: "destructive" as const, + }; + } + if (exp && exp - now <= EXPIRING_SOON_MS) { + return { + code: "expiringSoon" as const, + badgeVariant: "outline" as const, + }; + } + return { + code: "active" as const, + badgeVariant: "default" as const, + }; + }; + + // Format expiration time + const formatExpiry = (expiresAt: Date | null | undefined) => { + if (!expiresAt) return tUsers("neverExpires"); + const relative = formatDateDistance(expiresAt, new Date(), locale, { addSuffix: true }); + const absolute = formatDate(expiresAt, "yyyy-MM-dd", locale); + return `${relative} · ${absolute}`; + }; + + // Handle renew user + const handleRenew = (userId: number, targetDate: Date, enableUser?: boolean) => { + startTransition(async () => { + setPendingUserId(userId); + try { + const res = await renewUser(userId, { + expiresAt: targetDate.toISOString(), + enableUser, + }); + if (!res.ok) { + toast.error(res.error || t("actions.failed")); + return; + } + toast.success(t("actions.success")); + } catch (error) { + console.error("[UserList] renewUser failed", error); + toast.error(t("actions.failed")); + } finally { + setPendingUserId(null); + } + }); + }; + + // Handle toggle user enabled status + const handleToggle = (user: UserDisplay, enabled: boolean) => { + startTransition(async () => { + setPendingUserId(user.id); + try { + const res = await toggleUserEnabled(user.id, enabled); + if (!res.ok) { + toast.error(res.error || t("actions.failed")); + return; + } + toast.success(t("actions.success")); + } catch (error) { + console.error("[UserList] toggleUserEnabled failed", error); + toast.error(t("actions.failed")); + } finally { + setPendingUserId(null); + } + }); + }; + + // Handle custom renew with date dialog + const handleCustomRenew = (user: UserDisplay) => { + setCustomRenewDialog({ open: true, user }); + setCustomDate(""); + setEnableOnRenew(!user.isEnabled); // 如果用户已禁用,默认勾选启用 + }; + + // 确认自定义续期 + const handleConfirmCustomRenew = () => { + if (!customRenewDialog.user || !customDate) { + toast.error(t("actions.invalidDate")); + return; + } + const parsed = new Date(customDate); + if (Number.isNaN(parsed.getTime())) { + toast.error(t("actions.invalidDate")); + return; + } + handleRenew(customRenewDialog.user.id, parsed, enableOnRenew ? true : undefined); + setCustomRenewDialog({ open: false, user: null }); + }; + + const now = Date.now(); + + // Transform user data to list items + const listItems: Array<{ user: UserDisplay; item: ListItemData }> = useMemo( + () => + users.map((user) => { + const statusInfo = getStatusInfo(user, now); + const activeKeys = user.keys.filter((k) => k.status === "enabled").length; + return { + user, + item: { + id: user.id, + title: user.name, + subtitle: user.note, + badge: { + text: t(`status.${statusInfo.code}`), + variant: statusInfo.badgeVariant, + }, + tags: user.tags, + metadata: [ + { + label: t("activeKeys"), + value: activeKeys.toString(), + }, + { + label: t("totalKeys"), + value: user.keys.length.toString(), + }, + { + label: t("expiresAt"), + value: formatExpiry(user.expiresAt ?? null), + }, + ], + }, + }; + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [users, now, locale, t, tUsers] + ); // 特别设计的空状态 - 仅管理员可见 const emptyStateComponent = @@ -72,18 +229,139 @@ export function UserList({ users, activeUserId, onUserSelect, currentUser }: Use > {users.length > 0 ? (
- {listItems.map((item) => ( + {listItems.map(({ user, item }) => ( onUserSelect(item.id as number)} compact + actions={ + isAdmin ? ( +
e.stopPropagation()}> + + + + + + + handleRenew( + user.id, + addDays(new Date(), 30), + user.isEnabled ? undefined : true + ) + } + > + {t("actions.renew30d")} + + + handleRenew( + user.id, + addDays(new Date(), 90), + user.isEnabled ? undefined : true + ) + } + > + {t("actions.renew90d")} + + + handleRenew( + user.id, + addDays(new Date(), 365), + user.isEnabled ? undefined : true + ) + } + > + {t("actions.renew1y")} + + + handleCustomRenew(user)}> + {t("actions.renewCustom")} + + + + +
+ ) : undefined + } /> ))}
) : null} + + {/* 自定义续期对话框 */} + { + if (!open) setCustomRenewDialog({ open: false, user: null }); + }} + > + + + {t("actions.customRenewTitle")} + + {t("actions.customRenewDescription", { + userName: customRenewDialog.user?.name || "", + })} + + +
+
+ + setCustomDate(e.target.value)} + min={new Date().toISOString().split("T")[0]} + /> +
+ {customRenewDialog.user && !customRenewDialog.user.isEnabled && ( +
+ + +
+ )} +
+ + + + +
+
); } diff --git a/src/app/v1/_lib/proxy/auth-guard.ts b/src/app/v1/_lib/proxy/auth-guard.ts index c496fc5f5..dd5ebf28c 100644 --- a/src/app/v1/_lib/proxy/auth-guard.ts +++ b/src/app/v1/_lib/proxy/auth-guard.ts @@ -1,5 +1,6 @@ import { logger } from "@/lib/logger"; import { validateApiKeyAndGetUser } from "@/repository/key"; +import { markUserExpired } from "@/repository/user"; import { GEMINI_PROTOCOL } from "../gemini/protocol"; import { ProxyResponses } from "./responses"; import type { AuthState, ProxySession } from "./session"; @@ -76,6 +77,35 @@ export class ProxyAuthenticator { return { user: null, key: null, apiKey, success: false }; } + // Check user status and expiration + const { user } = authResult; + + // 1. Check if user is disabled + if (!user.isEnabled) { + logger.warn("[ProxyAuthenticator] User is disabled", { + userId: user.id, + userName: user.name, + }); + return { user: null, key: null, apiKey, success: false }; + } + + // 2. Check if user is expired (lazy expiration check) + if (user.expiresAt && user.expiresAt.getTime() <= Date.now()) { + logger.warn("[ProxyAuthenticator] User has expired", { + userId: user.id, + userName: user.name, + expiresAt: user.expiresAt.toISOString(), + }); + // Best-effort lazy mark user as disabled (idempotent) + markUserExpired(user.id).catch((error) => { + logger.error("[ProxyAuthenticator] Failed to mark user as expired", { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + return { user: null, key: null, apiKey, success: false }; + } + logger.debug("[ProxyAuthenticator] Authentication successful", { userId: authResult.user.id, userName: authResult.user.name, diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 8a2d3c80b..e1a8c2ac7 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -35,12 +35,21 @@ export const users = pgTable('users', { limitMonthlyUsd: numeric('limit_monthly_usd', { precision: 10, scale: 2 }), limitTotalUsd: numeric('limit_total_usd', { precision: 10, scale: 2 }), limitConcurrentSessions: integer('limit_concurrent_sessions'), + + // User status and expiry management + isEnabled: boolean('is_enabled').notNull().default(true), + expiresAt: timestamp('expires_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), deletedAt: timestamp('deleted_at', { withTimezone: true }), }, (table) => ({ - // 优化用户列表查询的复合索引(按角色排序,管理员优先) + // 优化用户列表查询的复合索引(按角色排序,管理员优先) usersActiveRoleSortIdx: index('idx_users_active_role_sort').on(table.deletedAt, table.role, table.id).where(sql`${table.deletedAt} IS NULL`), + // 优化过期用户查询的复合索引(用于定时任务),仅索引未删除的用户 + usersEnabledExpiresAtIdx: index('idx_users_enabled_expires_at') + .on(table.isEnabled, table.expiresAt) + .where(sql`${table.deletedAt} IS NULL`), // 基础索引 usersCreatedAtIdx: index('idx_users_created_at').on(table.createdAt), usersDeletedAtIdx: index('idx_users_deleted_at').on(table.deletedAt), diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 452784582..bb97a5f3c 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -26,6 +26,8 @@ export async function validateKey(keyString: string): Promise (val === "" ? undefined : val)), }); /** @@ -118,6 +125,12 @@ export const UpdateUserSchema = z.object({ .max(1000, "并发Session上限不能超过1000") .nullable() .optional(), + // User status and expiry management + isEnabled: z.boolean().optional(), + expiresAt: z + .string() + .optional() + .transform((val) => (!val || val === "" ? undefined : val)), }); /** diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 1141478b6..3c8b90637 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -20,6 +20,8 @@ export function toUser(dbUser: any): User { dbUser?.limitTotalUsd !== null && dbUser?.limitTotalUsd !== undefined ? parseFloat(dbUser.limitTotalUsd) : null, + isEnabled: dbUser?.isEnabled ?? true, + expiresAt: dbUser?.expiresAt ? new Date(dbUser.expiresAt) : null, createdAt: dbUser?.createdAt ? new Date(dbUser.createdAt) : new Date(), updatedAt: dbUser?.updatedAt ? new Date(dbUser.updatedAt) : new Date(), }; diff --git a/src/repository/key.ts b/src/repository/key.ts index 69dc915da..b43b8faea 100644 --- a/src/repository/key.ts +++ b/src/repository/key.ts @@ -334,6 +334,8 @@ export async function validateApiKeyAndGetUser( userDailyQuota: users.dailyLimitUsd, userProviderGroup: users.providerGroup, userLimitTotalUsd: users.limitTotalUsd, + userIsEnabled: users.isEnabled, + userExpiresAt: users.expiresAt, userCreatedAt: users.createdAt, userUpdatedAt: users.updatedAt, userDeletedAt: users.deletedAt, @@ -365,6 +367,8 @@ export async function validateApiKeyAndGetUser( dailyQuota: row.userDailyQuota, providerGroup: row.userProviderGroup, limitTotalUsd: row.userLimitTotalUsd, + isEnabled: row.userIsEnabled, + expiresAt: row.userExpiresAt, createdAt: row.userCreatedAt, updatedAt: row.userUpdatedAt, deletedAt: row.userDeletedAt, diff --git a/src/repository/user.ts b/src/repository/user.ts index ed2d65813..835214da7 100644 --- a/src/repository/user.ts +++ b/src/repository/user.ts @@ -19,6 +19,8 @@ export async function createUser(userData: CreateUserData): Promise { limitMonthlyUsd: userData.limitMonthlyUsd?.toString(), limitTotalUsd: userData.limitTotalUsd?.toString(), limitConcurrentSessions: userData.limitConcurrentSessions, + isEnabled: userData.isEnabled ?? true, + expiresAt: userData.expiresAt ?? null, }; const [user] = await db.insert(users).values(dbData).returning({ @@ -38,6 +40,8 @@ export async function createUser(userData: CreateUserData): Promise { limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, limitConcurrentSessions: users.limitConcurrentSessions, + isEnabled: users.isEnabled, + expiresAt: users.expiresAt, }); return toUser(user); @@ -62,6 +66,8 @@ export async function findUserList(limit: number = 50, offset: number = 0): Prom limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, limitConcurrentSessions: users.limitConcurrentSessions, + isEnabled: users.isEnabled, + expiresAt: users.expiresAt, }) .from(users) .where(isNull(users.deletedAt)) @@ -91,6 +97,8 @@ export async function findUserById(id: number): Promise { limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, limitConcurrentSessions: users.limitConcurrentSessions, + isEnabled: users.isEnabled, + expiresAt: users.expiresAt, }) .from(users) .where(and(eq(users.id, id), isNull(users.deletedAt))); @@ -118,6 +126,8 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise< limitMonthlyUsd?: string; limitTotalUsd?: string | null; limitConcurrentSessions?: number; + isEnabled?: boolean; + expiresAt?: Date | null; } const dbData: UpdateDbData = { @@ -139,6 +149,8 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise< userData.limitTotalUsd === null ? null : userData.limitTotalUsd.toString(); if (userData.limitConcurrentSessions !== undefined) dbData.limitConcurrentSessions = userData.limitConcurrentSessions; + if (userData.isEnabled !== undefined) dbData.isEnabled = userData.isEnabled; + if (userData.expiresAt !== undefined) dbData.expiresAt = userData.expiresAt; const [user] = await db .update(users) @@ -161,6 +173,8 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise< limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, limitConcurrentSessions: users.limitConcurrentSessions, + isEnabled: users.isEnabled, + expiresAt: users.expiresAt, }); if (!user) return null; @@ -177,3 +191,17 @@ export async function deleteUser(id: number): Promise { return result.length > 0; } + +/** + * Mark an expired user as disabled (idempotent operation) + * Only updates if the user is currently enabled + */ +export async function markUserExpired(userId: number): Promise { + const result = await db + .update(users) + .set({ isEnabled: false, updatedAt: new Date() }) + .where(and(eq(users.id, userId), eq(users.isEnabled, true), isNull(users.deletedAt))) + .returning({ id: users.id }); + + return result.length > 0; +} diff --git a/src/types/user.ts b/src/types/user.ts index f16db53eb..3e2ce873e 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -19,6 +19,9 @@ export interface User { limitMonthlyUsd?: number; // 月消费上限(美元) limitTotalUsd?: number | null; // 总消费上限(美元) limitConcurrentSessions?: number; // 并发 Session 上限 + // User status and expiry management + isEnabled: boolean; // 用户启用状态 + expiresAt?: Date | null; // 用户过期时间 } /** @@ -37,6 +40,9 @@ export interface CreateUserData { limitMonthlyUsd?: number; limitTotalUsd?: number | null; limitConcurrentSessions?: number; + // User status and expiry management + isEnabled?: boolean; + expiresAt?: Date | null; } /** @@ -55,6 +61,9 @@ export interface UpdateUserData { limitMonthlyUsd?: number; limitTotalUsd?: number | null; limitConcurrentSessions?: number; + // User status and expiry management + isEnabled?: boolean; + expiresAt?: Date | null; } /** @@ -111,6 +120,9 @@ export interface UserDisplay { limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; limitConcurrentSessions?: number | null; + // User status and expiry management + isEnabled: boolean; // 用户启用状态 + expiresAt?: Date | null; // 用户过期时间 } /** From cf4b80618bfd14049329022982085ae84277c22a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 5 Dec 2025 01:33:43 +0000 Subject: [PATCH 2/3] chore: format code (feature-user-expiration-d5bd6a8) --- .../[locale]/dashboard/_components/user/key-list-header.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/[locale]/dashboard/_components/user/key-list-header.tsx b/src/app/[locale]/dashboard/_components/user/key-list-header.tsx index fe0ccf261..b79e3a84e 100644 --- a/src/app/[locale]/dashboard/_components/user/key-list-header.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-list-header.tsx @@ -117,7 +117,11 @@ export function KeyListHeader({ const now = Date.now(); const exp = activeUser.expiresAt ? new Date(activeUser.expiresAt).getTime() : null; - let status: { code: string; badge: string; variant: "default" | "secondary" | "destructive" | "outline" }; + let status: { + code: string; + badge: string; + variant: "default" | "secondary" | "destructive" | "outline"; + }; if (!activeUser.isEnabled) { status = { code: "disabled", badge: "已禁用", variant: "secondary" }; From 44f47824c8a46ef3351ce82733911ce030ef85c8 Mon Sep 17 00:00:00 2001 From: NightYu Date: Fri, 5 Dec 2025 18:20:31 +0800 Subject: [PATCH 3/3] feat(users): add expiration date validation and error messages - Introduced a new function `validateExpiresAt` to validate expiration dates, ensuring they are in the future and do not exceed 10 years. - Updated user-related actions to incorporate expiration date validation, returning appropriate error messages when validation fails. - Enhanced user forms to handle expiration dates correctly, converting input dates to the end of the day to avoid premature expiration. - Added new error messages in both English and Chinese for better user feedback regarding expiration date issues. This update improves the robustness of user management by enforcing expiration date rules and enhancing user experience with clear error messaging. --- messages/en/errors.json | 6 +- messages/zh-CN/errors.json | 6 +- src/actions/users.ts | 141 +++++++++++++++++- .../_components/user/forms/user-form.tsx | 11 +- src/lib/validation/schemas.ts | 17 ++- 5 files changed, 167 insertions(+), 14 deletions(-) diff --git a/messages/en/errors.json b/messages/en/errors.json index ab2762b98..559379484 100644 --- a/messages/en/errors.json +++ b/messages/en/errors.json @@ -62,5 +62,9 @@ "CREATE_USER_FAILED": "Failed to create user, please try again later", "UPDATE_USER_FAILED": "Failed to update user, please try again later", "DELETE_USER_FAILED": "Failed to delete user, please try again later", - "GET_USER_QUOTA_FAILED": "Failed to get user quota information" + "GET_USER_QUOTA_FAILED": "Failed to get user quota information", + "EXPIRES_AT_FIELD": "Expiration date", + "EXPIRES_AT_MUST_BE_FUTURE": "Expiration date must be in the future", + "EXPIRES_AT_TOO_FAR": "Expiration date cannot exceed 10 years", + "CANNOT_DISABLE_SELF": "Cannot disable your own account" } diff --git a/messages/zh-CN/errors.json b/messages/zh-CN/errors.json index 731bb36a5..3b3a220ae 100644 --- a/messages/zh-CN/errors.json +++ b/messages/zh-CN/errors.json @@ -61,5 +61,9 @@ "CREATE_USER_FAILED": "创建用户失败,请稍后重试", "UPDATE_USER_FAILED": "更新用户失败,请稍后重试", "DELETE_USER_FAILED": "删除用户失败,请稍后重试", - "GET_USER_QUOTA_FAILED": "获取用户限额使用情况失败" + "GET_USER_QUOTA_FAILED": "获取用户限额使用情况失败", + "EXPIRES_AT_FIELD": "过期时间", + "EXPIRES_AT_MUST_BE_FUTURE": "过期时间必须是未来时间", + "EXPIRES_AT_TOO_FAR": "过期时间不能超过10年", + "CANNOT_DISABLE_SELF": "不能禁用自己的账户" } diff --git a/src/actions/users.ts b/src/actions/users.ts index 4e4c2995d..2b1f6bb0d 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -21,6 +21,47 @@ import { createUser, deleteUser, findUserById, findUserList, updateUser } from " import type { UserDisplay } from "@/types/user"; import type { ActionResult } from "./types"; +/** + * 验证过期时间的公共函数 + * @param expiresAt - 过期时间 + * @param tError - 翻译函数 + * @returns 验证结果,如果有错误返回错误信息和错误码 + */ +async function validateExpiresAt( + expiresAt: Date, + tError: Awaited>>, + options: { allowPast?: boolean } = {} +): Promise<{ error: string; errorCode: string } | null> { + // 检查是否为有效日期 + if (Number.isNaN(expiresAt.getTime())) { + return { + error: tError("INVALID_FORMAT", { field: tError("EXPIRES_AT_FIELD") }), + errorCode: ERROR_CODES.INVALID_FORMAT, + }; + } + + // 拒绝过去或当前时间(可配置允许过去时间,用于立即让用户过期) + const now = new Date(); + if (!options.allowPast && expiresAt <= now) { + return { + error: tError("EXPIRES_AT_MUST_BE_FUTURE"), + errorCode: "EXPIRES_AT_MUST_BE_FUTURE", + }; + } + + // 限制最大续期时长(10年) + const maxExpiry = new Date(now); + maxExpiry.setFullYear(maxExpiry.getFullYear() + 10); + if (expiresAt > maxExpiry) { + return { + error: tError("EXPIRES_AT_TOO_FAR"), + errorCode: "EXPIRES_AT_TOO_FAR", + }; + } + + return null; +} + // 获取用户数据 export async function getUsers(): Promise { try { @@ -196,10 +237,38 @@ export async function addUser(data: { }); if (!validationResult.success) { + const issue = validationResult.error.issues[0]; + const { code, params } = await import("@/lib/utils/error-messages").then((m) => + m.zodErrorToCode(issue.code, { + minimum: "minimum" in issue ? issue.minimum : undefined, + maximum: "maximum" in issue ? issue.maximum : undefined, + type: "expected" in issue ? issue.expected : undefined, + received: "received" in issue ? issue.received : undefined, + validation: "validation" in issue ? issue.validation : undefined, + path: issue.path, + message: "message" in issue ? issue.message : undefined, + params: "params" in issue ? issue.params : undefined, + }) + ); + + // For custom errors with nested field keys, translate them + let translatedParams = params; + if (issue.code === "custom" && params?.field && typeof params.field === "string") { + try { + translatedParams = { + ...params, + field: tError(params.field as string), + }; + } catch { + // Keep original if translation fails + } + } + return { ok: false, error: formatZodError(validationResult.error), - errorCode: ERROR_CODES.INVALID_FORMAT, + errorCode: code, + errorParams: translatedParams, }; } @@ -281,10 +350,38 @@ export async function editUser( const validationResult = UpdateUserSchema.safeParse(data); if (!validationResult.success) { + const issue = validationResult.error.issues[0]; + const { code, params } = await import("@/lib/utils/error-messages").then((m) => + m.zodErrorToCode(issue.code, { + minimum: "minimum" in issue ? issue.minimum : undefined, + maximum: "maximum" in issue ? issue.maximum : undefined, + type: "expected" in issue ? issue.expected : undefined, + received: "received" in issue ? issue.received : undefined, + validation: "validation" in issue ? issue.validation : undefined, + path: issue.path, + message: "message" in issue ? issue.message : undefined, + params: "params" in issue ? issue.params : undefined, + }) + ); + + // For custom errors with nested field keys, translate them + let translatedParams = params; + if (issue.code === "custom" && params?.field && typeof params.field === "string") { + try { + translatedParams = { + ...params, + field: tError(params.field as string), + }; + } catch { + // Keep original if translation fails + } + } + return { ok: false, error: formatZodError(validationResult.error), - errorCode: ERROR_CODES.INVALID_FORMAT, + errorCode: code, + errorParams: translatedParams, }; } @@ -310,6 +407,18 @@ export async function editUser( }; } + // 如果设置了过期时间,进行验证 + if (data.expiresAt !== undefined && data.expiresAt !== null) { + const validationResult = await validateExpiresAt(data.expiresAt, tError, { allowPast: true }); + if (validationResult) { + return { + ok: false, + error: validationResult.error, + errorCode: validationResult.errorCode, + }; + } + } + // Update user with validated data await updateUser(userId, { name: validatedData.name, @@ -459,11 +568,24 @@ export async function renewUser( // Parse and validate expiration date const expiresAt = new Date(data.expiresAt); - if (isNaN(expiresAt.getTime())) { + + // 验证过期时间 + const validationResult = await validateExpiresAt(expiresAt, tError); + if (validationResult) { return { ok: false, - error: tError("INVALID_FORMAT"), - errorCode: ERROR_CODES.INVALID_FORMAT, + error: validationResult.error, + errorCode: validationResult.errorCode, + }; + } + + // 检查用户是否存在 + const user = await findUserById(userId); + if (!user) { + return { + ok: false, + error: tError("USER_NOT_FOUND"), + errorCode: ERROR_CODES.NOT_FOUND, }; } @@ -479,7 +601,14 @@ export async function renewUser( updateData.isEnabled = true; } - await updateUser(userId, updateData); + const updated = await updateUser(userId, updateData); + if (!updated) { + return { + ok: false, + error: tError("USER_NOT_FOUND"), + errorCode: ERROR_CODES.NOT_FOUND, + }; + } revalidatePath("/dashboard"); return { ok: true }; diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx index 28e6a11db..fb62ff56f 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx @@ -77,6 +77,13 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { expiresAt: user?.expiresAt ? user.expiresAt.toISOString().split("T")[0] : "", }, onSubmit: async (data) => { + // 将纯日期转换为当天结束时间(本地时区 23:59:59.999),避免默认 UTC 零点导致提前过期 + const toEndOfDay = (dateStr: string) => { + const d = new Date(dateStr); + d.setHours(23, 59, 59, 999); + return d; + }; + startTransition(async () => { try { let res; @@ -94,7 +101,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, isEnabled: data.isEnabled, - expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, + expiresAt: data.expiresAt ? toEndOfDay(data.expiresAt) : null, }); } else { res = await addUser({ @@ -110,7 +117,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, isEnabled: data.isEnabled, - expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, + expiresAt: data.expiresAt ? toEndOfDay(data.expiresAt) : null, }); } diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index f15cadfbe..fc91c2833 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -127,10 +127,19 @@ export const UpdateUserSchema = z.object({ .optional(), // User status and expiry management isEnabled: z.boolean().optional(), - expiresAt: z - .string() - .optional() - .transform((val) => (!val || val === "" ? undefined : val)), + expiresAt: z.preprocess( + (val) => { + // 兼容服务端传入的 Date 对象,统一转为字符串再走后续校验 + if (val instanceof Date) return val.toISOString(); + // null/undefined/空字符串 -> 视为未设置 + if (val === null || val === undefined || val === "") return undefined; + return val; + }, + z + .string() + .optional() + .transform((val) => (!val || val === "" ? undefined : val)) + ), }); /**