diff --git a/backend/app/api/endpoints/admin/system_config.py b/backend/app/api/endpoints/admin/system_config.py index 64184ebd2..6c1310375 100644 --- a/backend/app/api/endpoints/admin/system_config.py +++ b/backend/app/api/endpoints/admin/system_config.py @@ -4,7 +4,7 @@ """Admin system configuration endpoints.""" -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from app.api.dependencies import get_db @@ -229,9 +229,20 @@ async def mark_admin_setup_complete( Mark admin setup wizard as completed. This will prevent the wizard from showing on subsequent admin logins. + Requires that the admin password has been changed from default. + Returns: AdminSetupCompleteResponse: Contains success status and message """ + # Verify admin password has been changed from default + from app.services.admin_utils import is_admin_password_default + + if is_admin_password_default(db): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Please change the default password before completing setup", + ) + config = ( db.query(SystemConfig) .filter(SystemConfig.config_key == ADMIN_SETUP_CONFIG_KEY) diff --git a/backend/app/api/endpoints/health.py b/backend/app/api/endpoints/health.py index f37462198..a234ca2f9 100644 --- a/backend/app/api/endpoints/health.py +++ b/backend/app/api/endpoints/health.py @@ -35,12 +35,18 @@ def health_check(db: Session = Depends(get_db)): result = db.execute(text("SELECT COUNT(*) FROM users")) user_count = result.scalar() + # Check if admin password has been changed from default + from app.services.admin_utils import is_admin_password_default + + admin_password_changed = not is_admin_password_default(db) + return { "status": "healthy", "database": "connected", "users_initialized": user_count > 0, "user_count": user_count, "shutting_down": shutdown_manager.is_shutting_down, + "admin_password_changed": admin_password_changed, } except Exception as e: return {"status": "unhealthy", "database": "error", "error": str(e)} diff --git a/backend/app/api/endpoints/users.py b/backend/app/api/endpoints/users.py index 18546013d..7dd0df207 100644 --- a/backend/app/api/endpoints/users.py +++ b/backend/app/api/endpoints/users.py @@ -20,7 +20,7 @@ QuickAccessTeam, WelcomeConfigResponse, ) -from app.schemas.user import UserCreate, UserInDB, UserUpdate +from app.schemas.user import ChangePasswordRequest, UserCreate, UserInDB, UserUpdate from app.services.kind import kind_service from app.services.user import user_service @@ -80,6 +80,36 @@ async def update_current_user_endpoint( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) +@router.put("/me/password", response_model=UserInDB) +async def change_password( + request: ChangePasswordRequest, + db: Session = Depends(get_db), + current_user: User = Depends(security.get_current_user), +): + """Change current user's password""" + if request.new_password != request.confirm_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Passwords do not match", + ) + + # Prevent admin from "changing" to the same default password + if current_user.user_name == "admin": + from app.core.yaml_init import DEFAULT_ADMIN_Password + + if request.new_password == DEFAULT_ADMIN_Password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="New password cannot be the same as the default password", + ) + + # Hash and update the password + current_user.password_hash = security.get_password_hash(request.new_password) + db.commit() + db.refresh(current_user) + return current_user + + @router.delete("/me/git-token/{git_domain:path}", response_model=UserInDB) async def delete_git_token( git_domain: str, @@ -312,6 +342,7 @@ async def get_welcome_config( # Check admin setup status for admin users admin_setup_completed = None + admin_password_changed = None if current_user.role == "admin": setup_config = ( db.query(SystemConfig) @@ -323,6 +354,11 @@ async def get_welcome_config( else: admin_setup_completed = False + # Check if admin password has been changed from default + from app.services.admin_utils import is_admin_password_default + + admin_password_changed = not is_admin_password_default(db) + if not config: # Return default configuration return WelcomeConfigResponse( @@ -331,6 +367,7 @@ async def get_welcome_config( ], tips=[ChatTipItem(**tip) for tip in DEFAULT_SLOGAN_TIPS_CONFIG["tips"]], admin_setup_completed=admin_setup_completed, + admin_password_changed=admin_password_changed, ) config_value = config.config_value or {} @@ -344,6 +381,7 @@ async def get_welcome_config( for tip in config_value.get("tips", DEFAULT_SLOGAN_TIPS_CONFIG["tips"]) ], admin_setup_completed=admin_setup_completed, + admin_password_changed=admin_password_changed, ) diff --git a/backend/app/core/yaml_init.py b/backend/app/core/yaml_init.py index 28fc1a925..0f514c18e 100644 --- a/backend/app/core/yaml_init.py +++ b/backend/app/core/yaml_init.py @@ -23,6 +23,14 @@ logger = logging.getLogger(__name__) +# Default admin password plaintext - used to verify if admin still uses default credentials +DEFAULT_ADMIN_Password = "Wegent2025!" + +# Default admin password hash for "Wegent2025!" - used for initial user creation +DEFAULT_ADMIN_Password_HASH = ( + "$2b$12$5jQMrJGO8NMXmF90f/xnKeLtM/Deh912k4GRPx.q3nTGOg1e1IJzW" +) + def load_yaml_documents(file_path: Path) -> List[Dict[str, Any]]: """ @@ -64,7 +72,7 @@ def ensure_default_user(db: Session) -> tuple[int, bool]: # Default admin user (admin/Wegent2025!) admin_user = User( user_name="admin", - password_hash="$2b$12$5jQMrJGO8NMXmF90f/xnKeLtM/Deh912k4GRPx.q3nTGOg1e1IJzW", + password_hash=DEFAULT_ADMIN_Password_HASH, email="admin@example.com", git_info=[], is_active=True, diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index 20558c199..f751dbee5 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -242,6 +242,10 @@ class WelcomeConfigResponse(BaseModel): default=None, description="Whether admin setup wizard has been completed (only returned for admin users)", ) + admin_password_changed: Optional[bool] = Field( + default=None, + description="Whether admin password has been changed from default (only returned for admin users)", + ) # Public Retriever Management Schemas diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 8cb8faa85..3c375cca1 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel, ConfigDict, EmailStr, field_validator +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator class MCPProviderKeys(BaseModel): @@ -167,3 +167,10 @@ class CLIPollResponse(BaseModel): access_token: Optional[str] = None username: Optional[str] = None error: Optional[str] = None + + +class ChangePasswordRequest(BaseModel): + """Password change request model""" + + new_password: str = Field(..., min_length=6) + confirm_password: str = Field(..., min_length=6) diff --git a/backend/app/services/admin_utils.py b/backend/app/services/admin_utils.py new file mode 100644 index 000000000..465371e51 --- /dev/null +++ b/backend/app/services/admin_utils.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Utility functions for admin user operations.""" + +from sqlalchemy.orm import Session + +from app.core.security import verify_password +from app.core.yaml_init import DEFAULT_ADMIN_Password +from app.models.user import User + + +def is_admin_password_default(db: Session) -> bool: + """ + Check if the admin user's password is still the default value. + + Uses bcrypt verification to compare the known default plaintext password + against the stored hash. This correctly handles re-hashed passwords + (same plaintext with different salt) unlike direct hash comparison. + + Args: + db: Database session + + Returns: + True if the admin password is still the default, False if changed + or if the admin user does not exist. + """ + admin_user = db.query(User).filter(User.user_name == "admin").first() + if not admin_user: + return False + return verify_password(DEFAULT_ADMIN_Password, admin_user.password_hash) diff --git a/frontend/e2e/config/test-users.ts b/frontend/e2e/config/test-users.ts index 987f34a76..5505f3219 100644 --- a/frontend/e2e/config/test-users.ts +++ b/frontend/e2e/config/test-users.ts @@ -9,12 +9,23 @@ export interface TestUser { description: string } +/** + * Default admin password (before change) - used only for initial login in global-setup + */ +export const DEFAULT_ADMIN_Password = 'Wegent2025!' + +/** + * E2E admin password (after change) - used for all API calls after setup + */ +export const E2E_ADMIN_Password = 'WegentE2E2025!' + /** * Default admin user for E2E tests + * Note: password is the E2E password (changed during global-setup), not the default */ export const ADMIN_USER: TestUser = { username: 'admin', - password: 'Wegent2025!', + password: E2E_ADMIN_Password, role: 'admin', description: 'Default admin user with full access', } diff --git a/frontend/e2e/tests/admin/admin-public-models.spec.ts b/frontend/e2e/tests/admin/admin-public-models.spec.ts index 2ac91967a..72f24b50b 100644 --- a/frontend/e2e/tests/admin/admin-public-models.spec.ts +++ b/frontend/e2e/tests/admin/admin-public-models.spec.ts @@ -16,9 +16,23 @@ test.describe('Admin - Public Model Management', () => { await apiClient.login(ADMIN_USER.username, ADMIN_USER.password) // Mark admin setup as complete via API to prevent GlobalAdminSetupWizard from showing - await apiClient.markAdminSetupComplete().catch(() => { - // Ignore errors - setup may already be complete + // First change password (required before setup can be completed) + const passwordResult = await apiClient + .changeAdminPassword(ADMIN_USER.password, ADMIN_USER.password) + .catch((error: Error) => { + console.log(`password change request failed (may already be changed): ${error.message}`) + return null + }) + if (passwordResult && passwordResult.status >= 400) { + console.log(`password change API returned ${passwordResult.status} - may already be changed`) + } + const setupResult = await apiClient.markAdminSetupComplete().catch((error: Error) => { + console.log(`Setup complete request failed (may already be complete): ${error.message}`) + return null }) + if (setupResult && setupResult.status >= 400) { + console.log(`Setup complete API returned ${setupResult.status} - may already be complete`) + } // Navigate directly to admin page (already authenticated via global setup storageState) await adminPage.navigateToTab('public-models') diff --git a/frontend/e2e/tests/admin/admin-users.spec.ts b/frontend/e2e/tests/admin/admin-users.spec.ts index 294847557..62eef322a 100644 --- a/frontend/e2e/tests/admin/admin-users.spec.ts +++ b/frontend/e2e/tests/admin/admin-users.spec.ts @@ -17,9 +17,23 @@ test.describe('Admin - User Management', () => { await apiClient.login(ADMIN_USER.username, ADMIN_USER.password) // Mark admin setup as complete via API to prevent GlobalAdminSetupWizard from showing - await apiClient.markAdminSetupComplete().catch(() => { - // Ignore errors - setup may already be complete + // First change password (required before setup can be completed) + const passwordResult = await apiClient + .changeAdminPassword(ADMIN_USER.password, ADMIN_USER.password) + .catch((error: Error) => { + console.log(`password change request failed (may already be changed): ${error.message}`) + return null + }) + if (passwordResult && passwordResult.status >= 400) { + console.log(`password change API returned ${passwordResult.status} - may already be changed`) + } + const setupResult = await apiClient.markAdminSetupComplete().catch((error: Error) => { + console.log(`Setup complete request failed (may already be complete): ${error.message}`) + return null }) + if (setupResult && setupResult.status >= 400) { + console.log(`Setup complete API returned ${setupResult.status} - may already be complete`) + } // Navigate directly to admin page (already authenticated via global setup storageState) await adminPage.navigateToTab('users') diff --git a/frontend/e2e/tests/auth.spec.ts b/frontend/e2e/tests/auth.spec.ts index 7ef6f70f9..bdc4ad0c1 100644 --- a/frontend/e2e/tests/auth.spec.ts +++ b/frontend/e2e/tests/auth.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test' -import { TEST_USER } from '../utils/auth' +import { ADMIN_USER } from '../config/test-users' test.describe('Authentication', () => { test.use({ storageState: { cookies: [], origins: [] } }) @@ -24,11 +24,11 @@ test.describe('Authentication', () => { await page .locator('input[name="user_name"], input[name="username"], input[type="text"]') .first() - .fill(TEST_USER.username) + .fill(ADMIN_USER.username) await page .locator('input[name="password"], input[type="password"]') .first() - .fill(TEST_USER.password) + .fill(ADMIN_USER.password) // Submit form await page.locator('button[type="submit"]').click() @@ -93,8 +93,8 @@ test.describe('Logout', () => { .first() const passwordInput = page.locator('input[name="password"], input[type="password"]').first() - await usernameInput.fill(TEST_USER.username) - await passwordInput.fill(TEST_USER.password) + await usernameInput.fill(ADMIN_USER.username) + await passwordInput.fill(ADMIN_USER.password) await page.locator('button[type="submit"]').click() // Wait for login to complete diff --git a/frontend/e2e/tests/global-setup.ts b/frontend/e2e/tests/global-setup.ts index 9085747bb..03d7dc811 100644 --- a/frontend/e2e/tests/global-setup.ts +++ b/frontend/e2e/tests/global-setup.ts @@ -1,5 +1,6 @@ import { test as setup, expect } from '@playwright/test' import { login, TEST_USER } from '../utils/auth' +import { E2E_ADMIN_Password } from '../config/test-users' import * as path from 'path' import { promises as fsPromises } from 'fs' @@ -32,6 +33,27 @@ setup('authenticate', async ({ page, request }) => { // Get auth token from localStorage const token = await page.evaluate(() => localStorage.getItem('auth_token')) if (token) { + // First, change the admin password via API to satisfy the password change requirement + // This changes the password from the default to a dedicated E2E password + const passwordResponse = await page.request.put(`${apiBaseUrl}/api/users/me/password`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + new_password: E2E_ADMIN_Password, + confirm_password: E2E_ADMIN_Password, + }, + }) + if (passwordResponse.ok()) { + console.log('Admin password changed successfully to E2E password') + } else { + throw new Error( + `Failed to change admin password: ${passwordResponse.status()} - ${await passwordResponse.text()}` + ) + } + + // Now mark setup as complete const response = await page.request.post(`${apiBaseUrl}/api/admin/setup-complete`, { headers: { Authorization: `Bearer ${token}`, @@ -41,12 +63,13 @@ setup('authenticate', async ({ page, request }) => { if (response.ok()) { console.log('Admin setup marked as complete') } else { - console.warn(`Failed to mark admin setup as complete: ${response.status()}`) + throw new Error( + `Failed to mark admin setup as complete: ${response.status()} - ${await response.text()}` + ) } } } catch (error) { - console.warn('Warning: Could not mark admin setup as complete:', error) - // Continue anyway - this is not critical for all tests + throw new Error(`Admin setup failed during global-setup: ${error}`) } // Save storage state (cookies, localStorage) @@ -54,16 +77,32 @@ setup('authenticate', async ({ page, request }) => { console.log('Authentication successful, storage state saved') - // Mark admin setup as complete to prevent GlobalAdminSetupWizard from showing - // This is done via API to ensure it's completed before any tests run + // Double-check: Mark admin setup as complete using request context + // This is a backup in case the first attempt failed try { - // Get auth token from localStorage const authToken = await page.evaluate(() => { return localStorage.getItem('auth_token') }) if (authToken) { const baseURL = process.env.E2E_API_URL || 'http://localhost:8000' + + // Ensure password is changed first (idempotent - safe to call again) + await request + .put(`${baseURL}/api/users/me/password`, { + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + data: { + new_password: E2E_ADMIN_Password, + confirm_password: E2E_ADMIN_Password, + }, + }) + .catch(() => { + // Ignore - may already be done or password is already different from default + }) + const response = await request.post(`${baseURL}/api/admin/setup-complete`, { headers: { Authorization: `Bearer ${authToken}`, diff --git a/frontend/e2e/utils/api-client.ts b/frontend/e2e/utils/api-client.ts index 01bbd06bb..fcbb11e0c 100644 --- a/frontend/e2e/utils/api-client.ts +++ b/frontend/e2e/utils/api-client.ts @@ -496,9 +496,24 @@ export class ApiClient { return this.call('DELETE', `/api/admin/public-models/${modelId}`) } + /** + * Change admin password (required before marking setup as complete) + * Changes the admin password from the default to a new value + */ + async changeAdminPassword( + newPassword: string, + confirmPassword: string + ): Promise { + return this.call('PUT', '/api/users/me/password', { + new_password: newPassword, + confirm_password: confirmPassword, + }) + } + /** * Mark admin setup as complete (admin only) * This is used to dismiss the GlobalAdminSetupWizard in E2E tests + * Note: Requires admin password to have been changed from default first */ async markAdminSetupComplete(): Promise { return this.call('POST', '/api/admin/setup-complete') diff --git a/frontend/e2e/utils/auth.ts b/frontend/e2e/utils/auth.ts index 827d31190..e6069197e 100644 --- a/frontend/e2e/utils/auth.ts +++ b/frontend/e2e/utils/auth.ts @@ -1,11 +1,13 @@ import { Page } from '@playwright/test' +import { DEFAULT_ADMIN_Password } from '../config/test-users' /** * Test credentials for E2E testing + * Uses default admin password for initial login (before password change in global-setup) */ export const TEST_USER = { username: 'admin', - password: 'Wegent2025!', + password: DEFAULT_ADMIN_Password, } /** diff --git a/frontend/e2e/utils/cleanup.ts b/frontend/e2e/utils/cleanup.ts index 1f58cb152..873516851 100644 --- a/frontend/e2e/utils/cleanup.ts +++ b/frontend/e2e/utils/cleanup.ts @@ -1,5 +1,6 @@ import { APIRequestContext } from '@playwright/test' import { ApiClient, createApiClient } from './api-client' +import { ADMIN_USER } from '../config/test-users' /** * Test resource types that need cleanup @@ -29,7 +30,7 @@ export class CleanupManager { /** * Initialize with API client */ - async initialize(username: string = 'admin', password: string = 'Wegent2025!'): Promise { + async initialize(username: string = ADMIN_USER.username, password: string = ADMIN_USER.password): Promise { if (!this.request) { throw new Error('APIRequestContext is required for cleanup') } diff --git a/frontend/e2e/utils/helpers.ts b/frontend/e2e/utils/helpers.ts index 68216d59b..01eac8a96 100644 --- a/frontend/e2e/utils/helpers.ts +++ b/frontend/e2e/utils/helpers.ts @@ -1,6 +1,7 @@ import { Page } from '@playwright/test' import { ApiClient, createApiClient } from './api-client' import { APIRequestContext } from '@playwright/test' +import { ADMIN_USER } from '../config/test-users' /** * Smart wait utilities that replace hardcoded waitForTimeout calls @@ -366,8 +367,8 @@ export function truncate(str: string, maxLength: number): string { */ export async function createAuthenticatedApiClient( request: APIRequestContext, - username: string = 'admin', - password: string = 'Wegent2025!' + username: string = ADMIN_USER.username, + password: string = ADMIN_USER.password ): Promise { const apiClient = createApiClient(request) await apiClient.login(username, password) diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 6e49d2740..2e223e399 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -75,7 +75,7 @@ export default defineConfig({ }, dependencies: ['setup'], }, - /* API tests - no browser needed, no setup dependency */ + /* API tests - no browser needed, depends on setup for password change */ { name: 'api', testMatch: /api\/.*\.spec\.ts/, @@ -83,6 +83,7 @@ export default defineConfig({ // API tests don't need a browser baseURL: process.env.E2E_API_URL || 'http://localhost:8000', }, + dependencies: ['setup'], }, /* Performance tests */ { diff --git a/frontend/src/apis/user.ts b/frontend/src/apis/user.ts index e48bd9ba8..582574f3c 100644 --- a/frontend/src/apis/user.ts +++ b/frontend/src/apis/user.ts @@ -190,6 +190,13 @@ export const userApis = { return apiClient.get('/users/features') }, + /** + * Change current user's password + */ + async changePassword(data: { new_password: string; confirm_password: string }): Promise { + return apiClient.put('/users/me/password', data) + }, + isAuthenticated(): boolean { return isAuthenticated() }, diff --git a/frontend/src/features/admin/components/GlobalAdminSetupWizard.tsx b/frontend/src/features/admin/components/GlobalAdminSetupWizard.tsx index e5596d3f2..cebe30965 100644 --- a/frontend/src/features/admin/components/GlobalAdminSetupWizard.tsx +++ b/frontend/src/features/admin/components/GlobalAdminSetupWizard.tsx @@ -32,10 +32,11 @@ import { adminApis } from '@/apis/admin' import { userApis } from '@/apis/user' import { useUser } from '@/features/common/UserContext' import { useSetupWizard } from '../contexts/SetupWizardContext' +import SetupPasswordStep from './SetupPasswordStep' import SetupModelStep from './SetupModelStep' import SetupSkillStep from './SetupSkillStep' -const TOTAL_STEPS = 2 +const TOTAL_STEPS = 3 /** * Global Admin Setup Wizard component that shows on any page when: @@ -45,6 +46,11 @@ const TOTAL_STEPS = 2 * This component should be placed in the root layout to ensure it shows * regardless of which page the admin first lands on. * + * Setup steps: + * - Step 1: Change default password (mandatory, cannot be skipped) + * - Step 2: Configure public models (can be skipped) + * - Step 3: Configure public skills (can be skipped) + * * Setup status is fetched from the welcome-config API to avoid extra API calls. */ const GlobalAdminSetupWizard: React.FC = () => { @@ -58,6 +64,7 @@ const GlobalAdminSetupWizard: React.FC = () => { const [currentStep, setCurrentStep] = useState(1) const [isSkipDialogOpen, setIsSkipDialogOpen] = useState(false) const [completing, setCompleting] = useState(false) + const [passwordChanged, setPasswordChanged] = useState(false) // Sync open state with context so other components can know when wizard is open useEffect(() => { @@ -84,6 +91,16 @@ const GlobalAdminSetupWizard: React.FC = () => { // admin_setup_completed is only returned for admin users if (response.admin_setup_completed === false) { setOpen(true) + + // Check if password has already been changed + if (response.admin_password_changed === true) { + setPasswordChanged(true) + // Skip password step, start from step 2 + setCurrentStep(2) + } else { + setPasswordChanged(false) + setCurrentStep(1) + } } } catch (error) { console.error('Failed to check setup status:', error) @@ -104,10 +121,12 @@ const GlobalAdminSetupWizard: React.FC = () => { }, [currentStep]) const handlePrevious = useCallback(() => { - if (currentStep > 1) { + // Don't go back to password step if already changed + const minStep = passwordChanged ? 2 : 1 + if (currentStep > minStep) { setCurrentStep(prev => prev - 1) } - }, [currentStep]) + }, [currentStep, passwordChanged]) const handleComplete = useCallback(async () => { setCompleting(true) @@ -150,6 +169,15 @@ const GlobalAdminSetupWizard: React.FC = () => { } }, [toast, t]) + const handlePasswordChanged = useCallback(() => { + setPasswordChanged(true) + // Automatically move to next step after password change + setCurrentStep(2) + }, []) + + // Whether the current step allows skipping/closing + const canSkipOrClose = passwordChanged || currentStep > 1 + // Don't render anything while checking status or if not admin if (loading || userLoading || !user || user.role !== 'admin') { return null @@ -175,8 +203,10 @@ const GlobalAdminSetupWizard: React.FC = () => { const renderStepContent = () => { switch (currentStep) { case 1: - return + return case 2: + return + case 3: return default: return null @@ -189,7 +219,10 @@ const GlobalAdminSetupWizard: React.FC = () => { open={open} onOpenChange={nextOpen => { if (!nextOpen && !completing) { - setIsSkipDialogOpen(true) + if (canSkipOrClose) { + setIsSkipDialogOpen(true) + } + // If password not changed, don't allow closing } }} > @@ -222,18 +255,22 @@ const GlobalAdminSetupWizard: React.FC = () => {
- {currentStep > 1 && ( + {currentStep > 1 && (passwordChanged ? currentStep > 2 : true) && ( )} {currentStep < TOTAL_STEPS ? ( - ) : ( diff --git a/frontend/src/features/admin/components/SetupPasswordStep.tsx b/frontend/src/features/admin/components/SetupPasswordStep.tsx new file mode 100644 index 000000000..d3bf160f5 --- /dev/null +++ b/frontend/src/features/admin/components/SetupPasswordStep.tsx @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client' + +import React, { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { EyeIcon, EyeSlashIcon, ShieldCheckIcon } from '@heroicons/react/24/outline' +import { Loader2 } from 'lucide-react' +import { useToast } from '@/hooks/use-toast' +import { useTranslation } from '@/hooks/useTranslation' +import { userApis } from '@/apis/user' + +interface SetupPasswordStepProps { + onPasswordChanged: () => void +} + +/** + * Setup wizard step for changing the default admin password. + * This step is mandatory and cannot be skipped. + */ +const SetupPasswordStep: React.FC = ({ onPasswordChanged }) => { + const { t } = useTranslation('admin') + const { toast } = useToast() + + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showNewPassword, setShowNewPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async () => { + setError(null) + + // Validate password length + if (newPassword.length < 6) { + setError(t('setup_wizard.password_step.password_min_length')) + return + } + + // Validate password match + if (newPassword !== confirmPassword) { + setError(t('setup_wizard.password_step.password_mismatch')) + return + } + + setIsSubmitting(true) + try { + await userApis.changePassword({ + new_password: newPassword, + confirm_password: confirmPassword, + }) + toast({ + title: t('setup_wizard.password_step.change_password_success'), + }) + onPasswordChanged() + } catch (err) { + console.error('Failed to change password:', err) + setError(err instanceof Error ? err.message : t('setup_wizard.errors.complete_failed')) + } finally { + setIsSubmitting(false) + } + } + + return ( +
+ {/* Security notice */} +
+ +
+

+ {t('setup_wizard.password_step.title')} +

+

+ {t('setup_wizard.password_step.description')} +

+
+
+ + {/* Password form */} +
+ {/* New password */} +
+ +
+ setNewPassword(e.target.value)} + placeholder={t('setup_wizard.password_step.new_password')} + className="pr-10" + disabled={isSubmitting} + /> + +
+

+ {t('setup_wizard.password_step.password_min_length')} +

+
+ + {/* Confirm password */} +
+ +
+ setConfirmPassword(e.target.value)} + placeholder={t('setup_wizard.password_step.confirm_password')} + className="pr-10" + disabled={isSubmitting} + /> + +
+
+ + {/* Error message */} + {error &&
{error}
} + + {/* Submit button */} + +
+
+ ) +} + +export default SetupPasswordStep diff --git a/frontend/src/features/login/components/LoginForm.tsx b/frontend/src/features/login/components/LoginForm.tsx index cf4a653ad..9b52088fd 100644 --- a/frontend/src/features/login/components/LoginForm.tsx +++ b/frontend/src/features/login/components/LoginForm.tsx @@ -16,19 +16,20 @@ import LanguageSwitcher from '@/components/LanguageSwitcher' import { ThemeToggle } from '@/features/theme/ThemeToggle' import { POST_LOGIN_REDIRECT_KEY, sanitizeRedirectPath } from '@/features/login/constants' import Image from 'next/image' -import { getRuntimeConfigSync } from '@/lib/runtime-config' +import { getRuntimeConfigSync, getApiBaseUrl } from '@/lib/runtime-config' export default function LoginForm() { const { t } = useTranslation() const router = useRouter() const searchParams = useSearchParams() const [formData, setFormData] = useState({ - user_name: 'admin', - password: 'Wegent2025!', + user_name: '', + password: '', }) const [showPassword, setShowPassword] = useState(false) // Used antd message.error for unified error prompt, no need for local error state const [isLoading, setIsLoading] = useState(false) + const [showDefaultCredentials, setShowDefaultCredentials] = useState(false) // Get login mode configuration from runtime config const runtimeConfig = getRuntimeConfigSync() @@ -42,6 +43,31 @@ export default function LoginForm() { const defaultRedirect = paths.chat.getHref() const [redirectPath, setRedirectPath] = useState(defaultRedirect) + // Check if admin password has been changed to determine whether to show default credentials + useEffect(() => { + const checkAdminPasswordStatus = async () => { + try { + const baseUrl = getApiBaseUrl() + const response = await fetch(`${baseUrl}/health`) + if (response.ok) { + const data = await response.json() + if (data.admin_password_changed === false) { + // Admin still uses default password - show default credentials + setShowDefaultCredentials(true) + setFormData({ + user_name: 'admin', + password: 'Wegent2025!', + }) + } + } + } catch { + // Health check failed, keep form empty + } + } + + checkAdminPasswordStatus() + }, []) + const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target setFormData(prev => ({ @@ -206,10 +232,12 @@ export default function LoginForm() {
- {/* Show test account info */} -
- {t('common:login.test_account')} -
+ {/* Show test account info only when admin password hasn't been changed */} + {showDefaultCredentials && ( +
+ {t('common:login.test_account')} +
+ )} )} diff --git a/frontend/src/i18n/locales/en/admin.json b/frontend/src/i18n/locales/en/admin.json index cc1ab5708..703390cb2 100644 --- a/frontend/src/i18n/locales/en/admin.json +++ b/frontend/src/i18n/locales/en/admin.json @@ -641,6 +641,16 @@ "skip_confirm_message": "You can configure public models and skills later in the administration panel. Are you sure you want to skip the setup wizard?", "skip_confirm_yes": "Yes, skip", "skip_confirm_no": "Continue setup", + "password_step": { + "title": "Change Password", + "description": "For account security, please change the default password before continuing", + "new_password": "New Password", + "confirm_password": "Confirm Password", + "password_min_length": "Password must be at least 6 characters", + "password_mismatch": "Passwords do not match", + "change_password_success": "Password changed successfully", + "change_password_button": "Change Password" + }, "model_step": { "title": "Configure Public Models", "description": "Set up AI models that will be available to all users. You can add multiple models.", diff --git a/frontend/src/i18n/locales/zh-CN/admin.json b/frontend/src/i18n/locales/zh-CN/admin.json index 244218bbb..41f794d46 100644 --- a/frontend/src/i18n/locales/zh-CN/admin.json +++ b/frontend/src/i18n/locales/zh-CN/admin.json @@ -641,6 +641,16 @@ "skip_confirm_message": "您可以稍后在管理面板中配置公共模型和技能。确定要跳过设置向导吗?", "skip_confirm_yes": "是的,跳过", "skip_confirm_no": "继续设置", + "password_step": { + "title": "修改密码", + "description": "为了账户安全,请修改默认密码后再继续", + "new_password": "新密码", + "confirm_password": "确认密码", + "password_min_length": "密码长度至少为 6 位", + "password_mismatch": "两次输入的密码不一致", + "change_password_success": "密码修改成功", + "change_password_button": "确认修改" + }, "model_step": { "title": "配置公共模型", "description": "设置所有用户可用的 AI 模型。您可以添加多个模型。", diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 93d874131..5d56fea55 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -595,6 +595,8 @@ export interface WelcomeConfigResponse { tips: ChatTipItem[] /** Whether admin setup wizard has been completed (only returned for admin users) */ admin_setup_completed?: boolean | null + /** Whether admin password has been changed from default (only returned for admin users) */ + admin_password_changed?: boolean | null } // Default Teams Configuration Types