diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 78f31e0..f23b13d 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -254,6 +254,8 @@ func setupRouter( // ===== 认证路由 ===== api.HandleFunc("/auth/login", authHandler.Login).Methods(http.MethodPost) api.HandleFunc("/auth/refresh", authHandler.RefreshToken).Methods(http.MethodPost) + api.HandleFunc("/auth/status", authHandler.AuthStatus).Methods(http.MethodGet) + api.HandleFunc("/auth/setup", authHandler.Setup).Methods(http.MethodPost) protected := api.PathPrefix("").Subrouter() protected.Use(authMiddleware(authService)) diff --git a/backend/internal/adapter/input/http/rest/auth_handler.go b/backend/internal/adapter/input/http/rest/auth_handler.go index af00df1..26f5044 100644 --- a/backend/internal/adapter/input/http/rest/auth_handler.go +++ b/backend/internal/adapter/input/http/rest/auth_handler.go @@ -250,6 +250,54 @@ func loginRateLimitKey(r *http.Request, username string) string { return strings.ToLower(strings.TrimSpace(username)) + "|" + client } +// AuthStatus returns whether the system needs initial setup (no admin exists). +func (h *AuthHandler) AuthStatus(w http.ResponseWriter, r *http.Request) { + hasAdmin, err := h.authService.IsAdminExists(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to check status", err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]any{ + "needsSetup": !hasAdmin, + "hasUsers": hasAdmin, + }) +} + +// 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"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body", err.Error()) + return + } + if strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Password) == "" { + respondError(w, http.StatusBadRequest, "Missing fields", "username and password are required") + return + } + + _, 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, + }) +} + func (h *AuthHandler) convertUserResponse(ctx context.Context, user *entity.User) *dto.UserResponse { workspace, _ := h.authService.GetWorkspaceByID(ctx, user.WorkspaceID) return &dto.UserResponse{ diff --git a/backend/internal/domain/service/auth_service.go b/backend/internal/domain/service/auth_service.go index 88d11ef..5ac4a31 100644 --- a/backend/internal/domain/service/auth_service.go +++ b/backend/internal/domain/service/auth_service.go @@ -80,6 +80,55 @@ 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 +} + +// 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) { + hasAdmin, err := s.IsAdminExists(ctx) + if err != nil { + return nil, err + } + if hasAdmin { + return nil, entity.ErrForbidden + } + + // Hash password + passwordHash, err := s.passwordHasher.Hash(password) + if err != nil { + return nil, err + } + + if email == "" { + email = username + "@local.ocdp" + } + + 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 + } + if err := s.userRepo.Create(ctx, user); err != nil { + return nil, err + } + return user, nil +} + func (s *AuthService) Register(ctx context.Context, username, password, role, workspaceID string, opts UserWorkspaceOptions, isActive, mustChangePassword *bool) (*entity.User, error) { principal, err := authz.RequirePrincipal(ctx) if err != nil { diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 30aedb0..9f1273d 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -219,6 +219,10 @@ export type NodeMetricsResponse = GeneratedNodeMetricsResponse; 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); +export const setupInitialAdmin = (data: { username: string; password: string; email?: string }) => + AXIOS_INSTANCE.post<{ accessToken: string; refreshToken: string }>("/auth/setup", data).then((r) => r.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 e17050a..622169f 100644 --- a/frontend/src/features/auth/pages/AuthPage.tsx +++ b/frontend/src/features/auth/pages/AuthPage.tsx @@ -1,9 +1,9 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { LogIn, Loader2, ShieldCheck } from "lucide-react"; +import { LogIn, Loader2, ShieldCheck, UserPlus } from "lucide-react"; import { useToast } from "@/shared"; import { getErrorMessage } from "@/shared/utils/handleApiError"; -import { login as apiLogin, type AuthResponse } from "@/api"; +import { login as apiLogin, fetchAuthStatus, setupInitialAdmin, type AuthResponse } from "@/api"; type Props = { onLogin: (response: AuthResponse) => void; @@ -13,12 +13,76 @@ const AuthPage: React.FC = ({ onLogin }) => { const navigate = useNavigate(); const { success: toastSuccess, error: toastError, info: toastInfo } = useToast(); + // Auth status + const [needsSetup, setNeedsSetup] = useState(null); + const [checkingStatus, setCheckingStatus] = useState(true); + // Login form const [loginUsername, setLoginUsername] = useState(""); const [loginPassword, setLoginPassword] = useState(""); const [loginLoading, setLoginLoading] = useState(false); const [loginError, setLoginError] = useState(null); + // Setup form + const [setupUsername, setSetupUsername] = useState(""); + const [setupPassword, setSetupPassword] = useState(""); + const [setupEmail, setSetupEmail] = useState(""); + const [setupLoading, setSetupLoading] = useState(false); + const [setupError, setSetupError] = useState(null); + + // Check if setup is needed on mount + useEffect(() => { + let cancelled = false; + fetchAuthStatus() + .then((status) => { + if (!cancelled) { + setNeedsSetup(status.needsSetup); + setCheckingStatus(false); + } + }) + .catch(() => { + if (!cancelled) { + setNeedsSetup(false); // fall back to login on error + setCheckingStatus(false); + } + }); + return () => { cancelled = true; }; + }, []); + + // Handle setup (first admin registration) + const handleSetup = async (e: React.FormEvent) => { + e.preventDefault(); + if (!setupUsername || !setupPassword) return; + + setSetupLoading(true); + setSetupError(null); + toastInfo("Creating admin account...", { title: "Setup", durationMs: 1200 }); + + try { + await setupInitialAdmin({ + username: setupUsername, + password: setupPassword, + email: setupEmail || undefined, + }); + + // Login with the returned tokens + const loginResponse = await apiLogin({ + username: setupUsername, + password: setupPassword, + }); + + toastSuccess("Admin account created. Welcome!"); + onLogin(loginResponse); + navigate("/home", { replace: true }); + } catch (err: unknown) { + const msg = getErrorMessage(err, "Setup failed. Please try again later."); + setSetupError(msg); + toastError(msg); + } finally { + setSetupLoading(false); + } + }; + // Handle login const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); @@ -30,8 +94,6 @@ const AuthPage: React.FC = ({ onLogin }) => { try { const response = await apiLogin({ username: loginUsername, password: loginPassword }); - - // JWT 格式: { access_token, refresh_token, username, ... } toastSuccess(`Welcome, ${response.username}!`); onLogin(response); navigate("/home", { replace: true }); @@ -40,7 +102,6 @@ const AuthPage: React.FC = ({ onLogin }) => { const msg = raw?.message?.includes("Failed to fetch") ? "Network or CORS error: Please check backend CORS or use Vite proxy in development." : getErrorMessage(err, "Login failed. Please try again later."); - setLoginError(msg); toastError(msg); } finally { @@ -48,25 +109,36 @@ const AuthPage: React.FC = ({ onLogin }) => { } }; - return ( -
-