diff --git a/backend/internal/adapter/input/http/dto/auth_dto.go b/backend/internal/adapter/input/http/dto/auth_dto.go index 9e24b57..38d56e1 100644 --- a/backend/internal/adapter/input/http/dto/auth_dto.go +++ b/backend/internal/adapter/input/http/dto/auth_dto.go @@ -50,6 +50,13 @@ type LoginRequest struct { Password string `json:"password" binding:"required"` } +// SetupRequest 初始管理员注册请求 +type SetupRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Email string `json:"email,omitempty"` +} + // RefreshTokenRequest 刷新 Token 请求 type RefreshTokenRequest struct { RefreshToken string `json:"refreshToken" binding:"required"` diff --git a/backend/internal/adapter/input/http/rest/auth_handler.go b/backend/internal/adapter/input/http/rest/auth_handler.go index 26f5044..969ca33 100644 --- a/backend/internal/adapter/input/http/rest/auth_handler.go +++ b/backend/internal/adapter/input/http/rest/auth_handler.go @@ -265,11 +265,7 @@ func (h *AuthHandler) AuthStatus(w http.ResponseWriter, r *http.Request) { // Setup creates the first admin user. Only works when no admin exists. func (h *AuthHandler) Setup(w http.ResponseWriter, r *http.Request) { - var req struct { - Username string `json:"username"` - Password string `json:"password"` - Email string `json:"email"` - } + var req dto.SetupRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body", err.Error()) return @@ -279,19 +275,12 @@ func (h *AuthHandler) Setup(w http.ResponseWriter, r *http.Request) { return } - _, err := h.authService.SetupInitialAdmin(r.Context(), req.Username, req.Password, req.Email) + _, accessToken, refreshToken, err := h.authService.SetupInitialAdmin(r.Context(), req.Username, req.Password, req.Email) if err != nil { respondServiceError(w, err, "Failed to create initial admin") return } - // Generate access token immediately - accessToken, refreshToken, _, err := h.authService.Login(r.Context(), req.Username, req.Password) - if err != nil { - respondServiceError(w, err, "Admin created but login failed") - return - } - respondJSON(w, http.StatusCreated, map[string]string{ "accessToken": accessToken, "refreshToken": refreshToken, diff --git a/backend/internal/adapter/output/persistence/mock/user_repository_mock.go b/backend/internal/adapter/output/persistence/mock/user_repository_mock.go index b5c5b9f..076070d 100644 --- a/backend/internal/adapter/output/persistence/mock/user_repository_mock.go +++ b/backend/internal/adapter/output/persistence/mock/user_repository_mock.go @@ -85,6 +85,17 @@ func (r *UserRepositoryMock) Delete(ctx context.Context, id string) error { return nil } +func (r *UserRepositoryMock) AdminExists(ctx context.Context) (bool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + for _, u := range r.users { + if u.Role == "admin" { + return true, nil + } + } + return false, nil +} + func (r *UserRepositoryMock) List(ctx context.Context) ([]*entity.User, error) { r.mu.RLock() defer r.mu.RUnlock() diff --git a/backend/internal/adapter/output/persistence/postgres/user_repository.go b/backend/internal/adapter/output/persistence/postgres/user_repository.go index 7af0378..839ec89 100644 --- a/backend/internal/adapter/output/persistence/postgres/user_repository.go +++ b/backend/internal/adapter/output/persistence/postgres/user_repository.go @@ -120,6 +120,12 @@ func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*e } // Update 更新用户 +func (r *UserRepository) AdminExists(ctx context.Context) (bool, error) { + var exists bool + err := r.db.conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE role = 'admin')`).Scan(&exists) + return exists, err +} + func (r *UserRepository) Update(ctx context.Context, user *entity.User) error { user.UpdatedAt = time.Now() diff --git a/backend/internal/domain/repository/user_repository.go b/backend/internal/domain/repository/user_repository.go index e787ba4..5a913ed 100644 --- a/backend/internal/domain/repository/user_repository.go +++ b/backend/internal/domain/repository/user_repository.go @@ -24,4 +24,7 @@ type UserRepository interface { // List 列出所有用户 List(ctx context.Context) ([]*entity.User, error) + + // AdminExists checks whether any admin user exists (lightweight EXISTS query) + AdminExists(ctx context.Context) (bool, error) } diff --git a/backend/internal/domain/service/auth_service.go b/backend/internal/domain/service/auth_service.go index 5ac4a31..b72305e 100644 --- a/backend/internal/domain/service/auth_service.go +++ b/backend/internal/domain/service/auth_service.go @@ -80,53 +80,53 @@ type UserWorkspaceOptions struct { QuotaGPUMem string } -// IsAdminExists checks whether any admin user already exists in the database. -func (s *AuthService) IsAdminExists(ctx context.Context) (bool, error) { - users, err := s.userRepo.List(ctx) - if err != nil { - return false, err - } - for _, u := range users { - if u.Role == authz.RoleAdmin { - return true, nil - } - } - return false, nil +func defaultEmail(username string) string { + return username + "@local.ocdp" } -// SetupInitialAdmin creates the first admin user. Fails if an admin already exists. -func (s *AuthService) SetupInitialAdmin(ctx context.Context, username, password, email string) (*entity.User, error) { +// IsAdminExists checks whether any admin user already exists in the database. +func (s *AuthService) IsAdminExists(ctx context.Context) (bool, error) { + return s.userRepo.AdminExists(ctx) +} + +// SetupInitialAdmin creates the first admin user and returns access + refresh tokens. +// Fails if an admin already exists. +func (s *AuthService) SetupInitialAdmin(ctx context.Context, username, password, email string) (*entity.User, string, string, error) { hasAdmin, err := s.IsAdminExists(ctx) if err != nil { - return nil, err + return nil, "", "", err } if hasAdmin { - return nil, entity.ErrForbidden + return nil, "", "", entity.ErrForbidden } - // Hash password passwordHash, err := s.passwordHasher.Hash(password) if err != nil { - return nil, err + return nil, "", "", err } if email == "" { - email = username + "@local.ocdp" + email = defaultEmail(username) } user := entity.NewUser(username, passwordHash, email) user.ID = uuid.New().String() user.Role = authz.RoleAdmin user.WorkspaceID = entity.DefaultWorkspaceID - user.IsActive = true if err := user.Validate(); err != nil { - return nil, err + return nil, "", "", err } if err := s.userRepo.Create(ctx, user); err != nil { - return nil, err + return nil, "", "", err } - return user, nil + + // Generate tokens directly — avoid a separate login round-trip + accessToken, refreshToken, err := s.tokenGenerator.Generate(user.ID, user.Username, user.Role, user.WorkspaceID) + if err != nil { + return nil, "", "", err + } + return user, accessToken, refreshToken, nil } func (s *AuthService) Register(ctx context.Context, username, password, role, workspaceID string, opts UserWorkspaceOptions, isActive, mustChangePassword *bool) (*entity.User, error) { @@ -158,7 +158,7 @@ func (s *AuthService) Register(ctx context.Context, username, password, role, wo } // 默认生成占位邮箱,避免数据库约束失败 - email := username + "@local.ocdp" + email := defaultEmail(username) // 创建用户 user := entity.NewUser(username, passwordHash, email) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 9f1273d..da5bf8e 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -220,9 +220,9 @@ export const login = postAuthLogin; export const register = postAuthRegister; export const refreshAuth = postAuthRefresh; export const fetchAuthStatus = () => - AXIOS_INSTANCE.get<{ needsSetup: boolean; hasUsers: boolean }>("/auth/status").then((r) => r.data); + customAxiosInstance<{ needsSetup: boolean; hasUsers: boolean }>({ url: "/auth/status", method: "GET" }); export const setupInitialAdmin = (data: { username: string; password: string; email?: string }) => - AXIOS_INSTANCE.post<{ accessToken: string; refreshToken: string }>("/auth/setup", data).then((r) => r.data); + customAxiosInstance<{ accessToken: string; refreshToken: string }>({ url: "/auth/setup", method: "POST", data }); export const listUsers = () => customAxiosInstance({ url: "/users", method: "GET" }); export const createUser = (data: AdminCreateUserRequest) => customAxiosInstance({ url: "/users", method: "POST", data }); diff --git a/frontend/src/features/auth/pages/AuthPage.tsx b/frontend/src/features/auth/pages/AuthPage.tsx index 622169f..032cc8c 100644 --- a/frontend/src/features/auth/pages/AuthPage.tsx +++ b/frontend/src/features/auth/pages/AuthPage.tsx @@ -59,20 +59,20 @@ const AuthPage: React.FC = ({ onLogin }) => { toastInfo("Creating admin account...", { title: "Setup", durationMs: 1200 }); try { - await setupInitialAdmin({ + const result = await setupInitialAdmin({ username: setupUsername, password: setupPassword, email: setupEmail || undefined, }); - // Login with the returned tokens - const loginResponse = await apiLogin({ + // setupInitialAdmin returns tokens — use them directly to avoid redundant login + onLogin({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, username: setupUsername, - password: setupPassword, - }); + } as any); toastSuccess("Admin account created. Welcome!"); - onLogin(loginResponse); navigate("/home", { replace: true }); } catch (err: unknown) { const msg = getErrorMessage(err, "Setup failed. Please try again later.");