From 27e651c4f58b069365f00f74f5f85d11321c964c Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sun, 22 Mar 2026 10:44:24 -0400 Subject: [PATCH] wip 19 --- api/v1/api.gen.go | 99 ++++++++++++++++ api/v1/auth.go | 143 ++++++++++++++--------- api/v1/auth_test.go | 67 +++++++++-- api/v1/documents.go | 50 ++++---- api/v1/openapi.yaml | 38 ++++++ api/v1/server.go | 12 +- frontend/src/Routes.tsx | 2 + frontend/src/auth/AuthContext.tsx | 113 +++++++++++++----- frontend/src/components/Layout.tsx | 25 +++- frontend/src/components/Table.tsx | 8 +- frontend/src/generated/anthoLumeAPIV1.ts | 106 +++++++++++++++++ frontend/src/icons/FolderOpenIcon.tsx | 6 +- frontend/src/icons/LoadingIcon.tsx | 25 ++-- frontend/src/pages/ActivityPage.tsx | 11 +- frontend/src/pages/AdminImportPage.tsx | 3 +- frontend/src/pages/AdminPage.tsx | 13 ++- frontend/src/pages/AdminUsersPage.tsx | 17 +-- frontend/src/pages/DocumentsPage.tsx | 25 ++-- frontend/src/pages/HomePage.tsx | 22 ++-- frontend/src/pages/LoginPage.tsx | 26 ++++- frontend/src/pages/ProgressPage.tsx | 12 +- frontend/src/pages/RegisterPage.tsx | 116 ++++++++++++++++++ frontend/src/pages/SearchPage.tsx | 5 +- frontend/src/pages/SettingsPage.tsx | 28 ++--- frontend/src/utils/errors.ts | 27 +++++ 25 files changed, 774 insertions(+), 225 deletions(-) create mode 100644 frontend/src/pages/RegisterPage.tsx create mode 100644 frontend/src/utils/errors.ts diff --git a/api/v1/api.gen.go b/api/v1/api.gen.go index 3c41923..9343d36 100644 --- a/api/v1/api.gen.go +++ b/api/v1/api.gen.go @@ -538,6 +538,9 @@ type UpdateUserFormdataRequestBody UpdateUserFormdataBody // LoginJSONRequestBody defines body for Login for application/json ContentType. type LoginJSONRequestBody = LoginRequest +// RegisterJSONRequestBody defines body for Register for application/json ContentType. +type RegisterJSONRequestBody = LoginRequest + // CreateDocumentMultipartRequestBody defines body for CreateDocument for multipart/form-data ContentType. type CreateDocumentMultipartRequestBody CreateDocumentMultipartBody @@ -591,6 +594,9 @@ type ServerInterface interface { // Get current user info // (GET /auth/me) GetMe(w http.ResponseWriter, r *http.Request) + // User registration + // (POST /auth/register) + Register(w http.ResponseWriter, r *http.Request) // List documents // (GET /documents) GetDocuments(w http.ResponseWriter, r *http.Request, params GetDocumentsParams) @@ -961,6 +967,20 @@ func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) handler.ServeHTTP(w, r) } +// Register operation middleware +func (siw *ServerInterfaceWrapper) Register(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.Register(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetDocuments operation middleware func (siw *ServerInterfaceWrapper) GetDocuments(w http.ResponseWriter, r *http.Request) { @@ -1606,6 +1626,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc("POST "+options.BaseURL+"/auth/login", wrapper.Login) m.HandleFunc("POST "+options.BaseURL+"/auth/logout", wrapper.Logout) m.HandleFunc("GET "+options.BaseURL+"/auth/me", wrapper.GetMe) + m.HandleFunc("POST "+options.BaseURL+"/auth/register", wrapper.Register) m.HandleFunc("GET "+options.BaseURL+"/documents", wrapper.GetDocuments) m.HandleFunc("POST "+options.BaseURL+"/documents", wrapper.CreateDocument) m.HandleFunc("GET "+options.BaseURL+"/documents/{id}", wrapper.GetDocument) @@ -2072,6 +2093,50 @@ func (response GetMe401JSONResponse) VisitGetMeResponse(w http.ResponseWriter) e return json.NewEncoder(w).Encode(response) } +type RegisterRequestObject struct { + Body *RegisterJSONRequestBody +} + +type RegisterResponseObject interface { + VisitRegisterResponse(w http.ResponseWriter) error +} + +type Register201JSONResponse LoginResponse + +func (response Register201JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + + return json.NewEncoder(w).Encode(response) +} + +type Register400JSONResponse ErrorResponse + +func (response Register400JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type Register403JSONResponse ErrorResponse + +func (response Register403JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type Register500JSONResponse ErrorResponse + +func (response Register500JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type GetDocumentsRequestObject struct { Params GetDocumentsParams } @@ -2864,6 +2929,9 @@ type StrictServerInterface interface { // Get current user info // (GET /auth/me) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error) + // User registration + // (POST /auth/register) + Register(ctx context.Context, request RegisterRequestObject) (RegisterResponseObject, error) // List documents // (GET /documents) GetDocuments(ctx context.Context, request GetDocumentsRequestObject) (GetDocumentsResponseObject, error) @@ -3279,6 +3347,37 @@ func (sh *strictHandler) GetMe(w http.ResponseWriter, r *http.Request) { } } +// Register operation middleware +func (sh *strictHandler) Register(w http.ResponseWriter, r *http.Request) { + var request RegisterRequestObject + + var body RegisterJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.Register(ctx, request.(RegisterRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Register") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(RegisterResponseObject); ok { + if err := validResponse.VisitRegisterResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetDocuments operation middleware func (sh *strictHandler) GetDocuments(w http.ResponseWriter, r *http.Request, params GetDocumentsParams) { var request GetDocumentsRequestObject diff --git a/api/v1/auth.go b/api/v1/auth.go index 31902d4..d152630 100644 --- a/api/v1/auth.go +++ b/api/v1/auth.go @@ -36,44 +36,8 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe return Login401JSONResponse{Code: 401, Message: "Invalid credentials"}, nil } - // Get request and response from context (set by middleware) - r := s.getRequestFromContext(ctx) - w := s.getResponseWriterFromContext(ctx) - - if r == nil || w == nil { - return Login500JSONResponse{Code: 500, Message: "Internal context error"}, nil - } - - // Create session with cookie options for Vite proxy compatibility - store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey)) - if s.cfg.CookieEncKey != "" { - if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 { - store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey)) - } - } - - session, err := store.Get(r, "token") - if err != nil { - return Login401JSONResponse{Code: 401, Message: "Unauthorized"}, nil - } - - // Configure cookie options to work with Vite proxy - // For localhost development, we need SameSite to allow cookies across ports - session.Options.SameSite = http.SameSiteLaxMode - session.Options.HttpOnly = true - if !s.cfg.CookieSecure { - session.Options.Secure = false // Allow HTTP for localhost development - } else { - session.Options.Secure = true - } - - session.Values["authorizedUser"] = user.ID - session.Values["isAdmin"] = user.Admin - session.Values["expiresAt"] = time.Now().Unix() + (60 * 60 * 24 * 7) - session.Values["authHash"] = *user.AuthHash - - if err := session.Save(r, w); err != nil { - return Login500JSONResponse{Code: 500, Message: "Failed to create session"}, nil + if err := s.saveUserSession(ctx, user.ID, user.Admin, *user.AuthHash); err != nil { + return Login500JSONResponse{Code: 500, Message: err.Error()}, nil } return Login200JSONResponse{ @@ -82,6 +46,46 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe }, nil } +// POST /auth/register +func (s *Server) Register(ctx context.Context, request RegisterRequestObject) (RegisterResponseObject, error) { + if !s.cfg.RegistrationEnabled { + return Register403JSONResponse{Code: 403, Message: "Registration is disabled"}, nil + } + + if request.Body == nil { + return Register400JSONResponse{Code: 400, Message: "Invalid request body"}, nil + } + + req := *request.Body + if req.Username == "" || req.Password == "" { + return Register400JSONResponse{Code: 400, Message: "Invalid user or password"}, nil + } + + currentUsers, err := s.db.Queries.GetUsers(ctx) + if err != nil { + return Register500JSONResponse{Code: 500, Message: "Failed to create user"}, nil + } + + isAdmin := len(currentUsers) == 0 + if err := s.createUser(ctx, req.Username, &req.Password, &isAdmin); err != nil { + return Register400JSONResponse{Code: 400, Message: err.Error()}, nil + } + + user, err := s.db.Queries.GetUser(ctx, req.Username) + if err != nil { + return Register500JSONResponse{Code: 500, Message: "Failed to load created user"}, nil + } + + if err := s.saveUserSession(ctx, user.ID, user.Admin, *user.AuthHash); err != nil { + return Register500JSONResponse{Code: 500, Message: err.Error()}, nil + } + + return Register201JSONResponse{ + Username: user.ID, + IsAdmin: user.Admin, + }, nil +} + // POST /auth/logout func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) { _, ok := s.getSessionFromContext(ctx) @@ -96,28 +100,11 @@ func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (Logou return Logout401JSONResponse{Code: 401, Message: "Internal context error"}, nil } - // Create session store - store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey)) - if s.cfg.CookieEncKey != "" { - if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 { - store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey)) - } - } - - session, err := store.Get(r, "token") + session, err := s.getCookieSession(r) if err != nil { return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, nil } - // Configure cookie options (same as login) - session.Options.SameSite = http.SameSiteLaxMode - session.Options.HttpOnly = true - if !s.cfg.CookieSecure { - session.Options.Secure = false - } else { - session.Options.Secure = true - } - session.Values = make(map[any]any) if err := session.Save(r, w); err != nil { @@ -140,6 +127,50 @@ func (s *Server) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeRe }, nil } +func (s *Server) saveUserSession(ctx context.Context, username string, isAdmin bool, authHash string) error { + r := s.getRequestFromContext(ctx) + w := s.getResponseWriterFromContext(ctx) + if r == nil || w == nil { + return fmt.Errorf("internal context error") + } + + session, err := s.getCookieSession(r) + if err != nil { + return fmt.Errorf("unauthorized") + } + + session.Values["authorizedUser"] = username + session.Values["isAdmin"] = isAdmin + session.Values["expiresAt"] = time.Now().Unix() + (60 * 60 * 24 * 7) + session.Values["authHash"] = authHash + + if err := session.Save(r, w); err != nil { + return fmt.Errorf("failed to create session") + } + + return nil +} + +func (s *Server) getCookieSession(r *http.Request) (*sessions.Session, error) { + store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey)) + if s.cfg.CookieEncKey != "" { + if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 { + store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey)) + } + } + + session, err := store.Get(r, "token") + if err != nil { + return nil, fmt.Errorf("failed to get session: %w", err) + } + + session.Options.SameSite = http.SameSiteLaxMode + session.Options.HttpOnly = true + session.Options.Secure = s.cfg.CookieSecure + + return session, nil +} + // getSessionFromContext extracts authData from context func (s *Server) getSessionFromContext(ctx context.Context) (authData, bool) { auth, ok := ctx.Value("auth").(authData) diff --git a/api/v1/auth_test.go b/api/v1/auth_test.go index 5067da0..e6cb4cd 100644 --- a/api/v1/auth_test.go +++ b/api/v1/auth_test.go @@ -25,16 +25,16 @@ type AuthTestSuite struct { func (suite *AuthTestSuite) setupConfig() *config.Config { return &config.Config{ - ListenPort: "8080", - DBType: "memory", - DBName: "test", - ConfigPath: "/tmp", - CookieAuthKey: "test-auth-key-32-bytes-long-enough", - CookieEncKey: "0123456789abcdef", - CookieSecure: false, - CookieHTTPOnly: true, - Version: "test", - DemoMode: false, + ListenPort: "8080", + DBType: "memory", + DBName: "test", + ConfigPath: "/tmp", + CookieAuthKey: "test-auth-key-32-bytes-long-enough", + CookieEncKey: "0123456789abcdef", + CookieSecure: false, + CookieHTTPOnly: true, + Version: "test", + DemoMode: false, RegistrationEnabled: true, } } @@ -126,6 +126,51 @@ func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() { suite.Equal(http.StatusUnauthorized, w.Code) } +func (suite *AuthTestSuite) TestAPIRegister() { + reqBody := LoginRequest{ + Username: "newuser", + Password: "newpass", + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) + w := httptest.NewRecorder() + + suite.srv.ServeHTTP(w, req) + + suite.Equal(http.StatusCreated, w.Code) + + var resp LoginResponse + suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + suite.Equal("newuser", resp.Username) + suite.True(resp.IsAdmin, "first registered user should mirror legacy admin bootstrap behavior") + + cookies := w.Result().Cookies() + suite.Require().NotEmpty(cookies, "register should set a session cookie") + + user, err := suite.db.Queries.GetUser(suite.T().Context(), "newuser") + suite.Require().NoError(err) + suite.True(user.Admin) +} + +func (suite *AuthTestSuite) TestAPIRegisterDisabled() { + suite.cfg.RegistrationEnabled = false + suite.srv = NewServer(suite.db, suite.cfg, nil) + + reqBody := LoginRequest{ + Username: "newuser", + Password: "newpass", + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) + w := httptest.NewRecorder() + + suite.srv.ServeHTTP(w, req) + + suite.Equal(http.StatusForbidden, w.Code) +} + func (suite *AuthTestSuite) TestAPILogout() { suite.createTestUser("testuser", "testpass") cookie := suite.login("testuser", "testpass") @@ -163,4 +208,4 @@ func (suite *AuthTestSuite) TestAPIGetMeUnauthenticated() { suite.srv.ServeHTTP(w, req) suite.Equal(http.StatusUnauthorized, w.Code) -} \ No newline at end of file +} diff --git a/api/v1/documents.go b/api/v1/documents.go index d9c8b4b..92e8fb0 100644 --- a/api/v1/documents.go +++ b/api/v1/documents.go @@ -11,9 +11,9 @@ import ( "strings" "time" + log "github.com/sirupsen/logrus" "reichard.io/antholume/database" "reichard.io/antholume/metadata" - log "github.com/sirupsen/logrus" ) // GET /documents @@ -81,7 +81,7 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb LastRead: parseInterfaceTime(row.LastRead), CreatedAt: time.Now(), // Will be overwritten if we had a proper created_at from DB UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB - Deleted: false, // Default, should be overridden if available + Deleted: false, // Default, should be overridden if available } if row.Words != nil { wordCounts = append(wordCounts, WordCount{ @@ -217,10 +217,10 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb Isbn13: request.Body.Isbn13, Coverfile: coverFileName, // Preserve existing values for non-editable fields - Md5: currentDoc.Md5, - Basepath: currentDoc.Basepath, - Filepath: currentDoc.Filepath, - Words: currentDoc.Words, + Md5: currentDoc.Md5, + Basepath: currentDoc.Basepath, + Filepath: currentDoc.Filepath, + Words: currentDoc.Words, }) if err != nil { log.Error("UpsertDocument DB Error:", err) @@ -306,7 +306,7 @@ func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string { } // parseInterfaceTime converts an interface{} to time.Time for SQLC queries -func parseInterfaceTime(t interface{}) *time.Time { +func parseInterfaceTime(t any) *time.Time { if t == nil { return nil } @@ -380,7 +380,7 @@ func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverR } else { // Derive Path coverPath := filepath.Join(s.cfg.DataPath, "covers", *document.Coverfile) - + // Validate File Exists fileInfo, err := os.Stat(coverPath) if os.IsNotExist(err) { @@ -713,7 +713,7 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque } file := fileField[0] - + // Validate file extension if !strings.HasSuffix(strings.ToLower(file.Filename), ".epub") { return CreateDocument400JSONResponse{Code: 400, Message: "Only EPUB files are allowed"}, nil @@ -771,17 +771,17 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque // Document already exists existingDoc, _ := s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5) apiDoc := Document{ - Id: existingDoc.ID, - Title: *existingDoc.Title, - Author: *existingDoc.Author, + Id: existingDoc.ID, + Title: *existingDoc.Title, + Author: *existingDoc.Author, Description: existingDoc.Description, - Isbn10: existingDoc.Isbn10, - Isbn13: existingDoc.Isbn13, - Words: existingDoc.Words, + Isbn10: existingDoc.Isbn10, + Isbn13: existingDoc.Isbn13, + Words: existingDoc.Words, Filepath: existingDoc.Filepath, - CreatedAt: parseTime(existingDoc.CreatedAt), + CreatedAt: parseTime(existingDoc.CreatedAt), UpdatedAt: parseTime(existingDoc.UpdatedAt), - Deleted: existingDoc.Deleted, + Deleted: existingDoc.Deleted, } response := DocumentResponse{ Document: apiDoc, @@ -818,17 +818,17 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque } apiDoc := Document{ - Id: doc.ID, - Title: *doc.Title, - Author: *doc.Author, + Id: doc.ID, + Title: *doc.Title, + Author: *doc.Author, Description: doc.Description, - Isbn10: doc.Isbn10, - Isbn13: doc.Isbn13, - Words: doc.Words, + Isbn10: doc.Isbn10, + Isbn13: doc.Isbn13, + Words: doc.Words, Filepath: doc.Filepath, - CreatedAt: parseTime(doc.CreatedAt), + CreatedAt: parseTime(doc.CreatedAt), UpdatedAt: parseTime(doc.UpdatedAt), - Deleted: doc.Deleted, + Deleted: doc.Deleted, } response := DocumentResponse{ diff --git a/api/v1/openapi.yaml b/api/v1/openapi.yaml index a07790a..3a3d4ae 100644 --- a/api/v1/openapi.yaml +++ b/api/v1/openapi.yaml @@ -1182,6 +1182,44 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /auth/register: + post: + summary: User registration + operationId: register + tags: + - Auth + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + 201: + description: Successful registration + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + 400: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 403: + description: Registration disabled + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + 500: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /auth/logout: post: summary: User logout diff --git a/api/v1/server.go b/api/v1/server.go index 10c084a..89dc59a 100644 --- a/api/v1/server.go +++ b/api/v1/server.go @@ -46,8 +46,8 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S ctx = context.WithValue(ctx, "request", r) ctx = context.WithValue(ctx, "response", w) - // Skip auth for login and info endpoints - cover and file require auth via cookies - if operationID == "Login" || operationID == "GetInfo" { + // Skip auth for public auth and info endpoints - cover and file require auth via cookies + if operationID == "Login" || operationID == "Register" || operationID == "GetInfo" { return handler(ctx, w, r, request) } @@ -92,10 +92,8 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S // GetInfo returns server information func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) { return GetInfo200JSONResponse{ - Version: s.cfg.Version, - SearchEnabled: s.cfg.SearchEnabled, - RegistrationEnabled: s.cfg.RegistrationEnabled, + Version: s.cfg.Version, + SearchEnabled: s.cfg.SearchEnabled, + RegistrationEnabled: s.cfg.RegistrationEnabled, }, nil } - - diff --git a/frontend/src/Routes.tsx b/frontend/src/Routes.tsx index 884d655..0c49a89 100644 --- a/frontend/src/Routes.tsx +++ b/frontend/src/Routes.tsx @@ -8,6 +8,7 @@ import ActivityPage from './pages/ActivityPage'; import SearchPage from './pages/SearchPage'; import SettingsPage from './pages/SettingsPage'; import LoginPage from './pages/LoginPage'; +import RegisterPage from './pages/RegisterPage'; import AdminPage from './pages/AdminPage'; import AdminImportPage from './pages/AdminImportPage'; import AdminImportResultsPage from './pages/AdminImportResultsPage'; @@ -118,6 +119,7 @@ export function Routes() { /> } /> + } /> ); } diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index 80d779f..04c9750 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -1,6 +1,13 @@ import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; -import { useLogin, useLogout, useGetMe } from '../generated/anthoLumeAPIV1'; +import { + getGetMeQueryKey, + useLogin, + useLogout, + useGetMe, + useRegister, +} from '../generated/anthoLumeAPIV1'; interface AuthState { isAuthenticated: boolean; @@ -10,6 +17,7 @@ interface AuthState { interface AuthContextType extends AuthState { login: (_username: string, _password: string) => Promise; + register: (_username: string, _password: string) => Promise; logout: () => void; } @@ -19,27 +27,23 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [authState, setAuthState] = useState({ isAuthenticated: false, user: null, - isCheckingAuth: true, // Start with checking state to prevent redirects during initial load + isCheckingAuth: true, }); const loginMutation = useLogin(); + const registerMutation = useRegister(); const logoutMutation = useLogout(); - // Always call /me to check authentication status const { data: meData, error: meError, isLoading: meLoading } = useGetMe(); + const queryClient = useQueryClient(); const navigate = useNavigate(); - // Update auth state based on /me endpoint response useEffect(() => { setAuthState(prev => { if (meLoading) { - // Still checking authentication - console.log('[AuthContext] Checking authentication status...'); return { ...prev, isCheckingAuth: true }; } else if (meData?.data && meData.status === 200) { - // User is authenticated - check that response has valid data - console.log('[AuthContext] User authenticated:', meData.data); const userData = 'username' in meData.data ? meData.data : null; return { isAuthenticated: true, @@ -47,16 +51,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { isCheckingAuth: false, }; } else if (meError || (meData && meData.status === 401)) { - // User is not authenticated or error occurred - console.log('[AuthContext] User not authenticated:', meError?.message || String(meError)); return { isAuthenticated: false, user: null, isCheckingAuth: false, }; } - console.log('[AuthContext] Unexpected state - checking...'); - return { ...prev, isCheckingAuth: false }; // Assume not authenticated if we can't determine + + return { ...prev, isCheckingAuth: false }; }); }, [meData, meError, meLoading]); @@ -70,41 +72,92 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, }); - // The backend uses session-based authentication, so no token to store - // The session cookie is automatically set by the browser + if (response.status !== 200 || !('username' in response.data)) { + setAuthState({ + isAuthenticated: false, + user: null, + isCheckingAuth: false, + }); + throw new Error('Login failed'); + } + setAuthState({ isAuthenticated: true, - user: - 'username' in response.data - ? (response.data as { username: string; is_admin: boolean }) - : null, + user: response.data as { username: string; is_admin: boolean }, isCheckingAuth: false, }); + await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); navigate('/'); } catch (_error) { - console.error('[AuthContext] Login failed:', _error); - throw new Error('Login failed'); - } - }, - [loginMutation, navigate] - ); - - const logout = useCallback(() => { - logoutMutation.mutate(undefined, { - onSuccess: () => { setAuthState({ isAuthenticated: false, user: null, isCheckingAuth: false, }); + throw new Error('Login failed'); + } + }, + [loginMutation, navigate, queryClient] + ); + + const register = useCallback( + async (username: string, password: string) => { + try { + const response = await registerMutation.mutateAsync({ + data: { + username, + password, + }, + }); + + if (response.status !== 201 || !('username' in response.data)) { + setAuthState({ + isAuthenticated: false, + user: null, + isCheckingAuth: false, + }); + throw new Error('Registration failed'); + } + + setAuthState({ + isAuthenticated: true, + user: response.data as { username: string; is_admin: boolean }, + isCheckingAuth: false, + }); + + await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); + navigate('/'); + } catch (_error) { + setAuthState({ + isAuthenticated: false, + user: null, + isCheckingAuth: false, + }); + throw new Error('Registration failed'); + } + }, + [navigate, queryClient, registerMutation] + ); + + const logout = useCallback(() => { + logoutMutation.mutate(undefined, { + onSuccess: async () => { + setAuthState({ + isAuthenticated: false, + user: null, + isCheckingAuth: false, + }); + await queryClient.removeQueries({ queryKey: getGetMeQueryKey() }); navigate('/login'); }, }); - }, [logoutMutation, navigate]); + }, [logoutMutation, navigate, queryClient]); return ( - {children} + + {children} + ); } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a1299d2..6699cfa 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -9,7 +9,9 @@ export default function Layout() { const location = useLocation(); const { isAuthenticated, user, logout, isCheckingAuth } = useAuth(); const { data } = useGetMe(isAuthenticated ? {} : undefined); - const userData = data?.data || user; + const fetchedUser = + data?.status === 200 && data.data && 'username' in data.data ? data.data : null; + const userData = user ?? fetchedUser; const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false); const dropdownRef = useRef(null); @@ -34,15 +36,26 @@ export default function Layout() { // Get current page title const navItems = [ - { path: '/', title: 'Home' }, + { path: '/admin/import-results', title: 'Admin - Import' }, + { path: '/admin/import', title: 'Admin - Import' }, + { path: '/admin/users', title: 'Admin - Users' }, + { path: '/admin/logs', title: 'Admin - Logs' }, + { path: '/admin', title: 'Admin - General' }, { path: '/documents', title: 'Documents' }, { path: '/progress', title: 'Progress' }, { path: '/activity', title: 'Activity' }, { path: '/search', title: 'Search' }, { path: '/settings', title: 'Settings' }, + { path: '/', title: 'Home' }, ]; const currentPageTitle = - navItems.find(item => location.pathname === item.path)?.title || 'Documents'; + navItems.find(item => + item.path === '/' ? location.pathname === item.path : location.pathname.startsWith(item.path) + )?.title || 'Home'; + + useEffect(() => { + document.title = `AnthoLume - ${currentPageTitle}`; + }, [currentPageTitle]); // Show loading while checking authentication status if (isCheckingAuth) { @@ -62,7 +75,9 @@ export default function Layout() { {/* Header Title */} -

{currentPageTitle}

+

+ {currentPageTitle} +

{/* User Dropdown */}
-
+
{ +export interface Column> { key: keyof T; header: string; - render?: (value: any, _row: T, _index: number) => React.ReactNode; + render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode; className?: string; } -export interface TableProps { +export interface TableProps> { columns: Column[]; data: T[]; loading?: boolean; @@ -58,7 +58,7 @@ function SkeletonTable({ ); } -export function Table>({ +export function Table>({ columns, data, loading = false, diff --git a/frontend/src/generated/anthoLumeAPIV1.ts b/frontend/src/generated/anthoLumeAPIV1.ts index cdeda1c..4e9b3de 100644 --- a/frontend/src/generated/anthoLumeAPIV1.ts +++ b/frontend/src/generated/anthoLumeAPIV1.ts @@ -1690,6 +1690,112 @@ export const useLogin = { + + + + + return `/api/v1/auth/register` +} + +export const register = async (loginRequest: LoginRequest, options?: RequestInit): Promise => { + + const res = await fetch(getRegisterUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + loginRequest,) + } +) + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: registerResponse['data'] = body ? JSON.parse(body) : {} + return { data, status: res.status, headers: res.headers } as registerResponse +} + + + + +export const getRegisterMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: LoginRequest}, TContext>, fetch?: RequestInit} +): UseMutationOptions>, TError,{data: LoginRequest}, TContext> => { + +const mutationKey = ['register']; +const {mutation: mutationOptions, fetch: fetchOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, fetch: undefined}; + + + + + const mutationFn: MutationFunction>, {data: LoginRequest}> = (props) => { + const {data} = props ?? {}; + + return register(data,fetchOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type RegisterMutationResult = NonNullable>> + export type RegisterMutationBody = LoginRequest + export type RegisterMutationError = ErrorResponse + + /** + * @summary User registration + */ +export const useRegister = (options?: { mutation?:UseMutationOptions>, TError,{data: LoginRequest}, TContext>, fetch?: RequestInit} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: LoginRequest}, + TContext + > => { + return useMutation(getRegisterMutationOptions(options), queryClient); + } + /** * @summary User logout */ diff --git a/frontend/src/icons/FolderOpenIcon.tsx b/frontend/src/icons/FolderOpenIcon.tsx index f5237f7..d99929f 100644 --- a/frontend/src/icons/FolderOpenIcon.tsx +++ b/frontend/src/icons/FolderOpenIcon.tsx @@ -6,7 +6,11 @@ interface FolderOpenIconProps { disabled?: boolean; } -export function FolderOpenIcon({ size = 24, className = '', disabled = false }: FolderOpenIconProps) { +export function FolderOpenIcon({ + size = 24, + className = '', + disabled = false, +}: FolderOpenIconProps) { return ( ); diff --git a/frontend/src/pages/ActivityPage.tsx b/frontend/src/pages/ActivityPage.tsx index f11589e..005a8a5 100644 --- a/frontend/src/pages/ActivityPage.tsx +++ b/frontend/src/pages/ActivityPage.tsx @@ -1,17 +1,18 @@ import { Link } from 'react-router-dom'; import { useGetActivity } from '../generated/anthoLumeAPIV1'; +import type { Activity } from '../generated/model'; import { Table } from '../components/Table'; import { formatDuration } from '../utils/formatters'; export default function ActivityPage() { const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 }); - const activities = data?.data?.activities; + const activities = data?.status === 200 ? data.data.activities : []; const columns = [ { key: 'document_id' as const, header: 'Document', - render: (_: any, row: any) => ( + render: (_value: Activity['document_id'], row: Activity) => ( value || 'N/A', + render: (value: Activity['start_time']) => value || 'N/A', }, { key: 'duration' as const, header: 'Duration', - render: (value: any) => { + render: (value: Activity['duration']) => { return formatDuration(value || 0); }, }, { key: 'end_percentage' as const, header: 'Percent', - render: (value: any) => (value != null ? `${value}%` : '0%'), + render: (value: Activity['end_percentage']) => (value != null ? `${value}%` : '0%'), }, ]; diff --git a/frontend/src/pages/AdminImportPage.tsx b/frontend/src/pages/AdminImportPage.tsx index 9a7b60e..1d4ffca 100644 --- a/frontend/src/pages/AdminImportPage.tsx +++ b/frontend/src/pages/AdminImportPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1'; +import { getErrorMessage } from '../utils/errors'; import { Button } from '../components/Button'; import { FolderOpenIcon } from '../icons'; import { useToasts } from '../components/ToastContext'; @@ -50,7 +51,7 @@ export default function AdminImportPage() { }, 1500); }, onError: error => { - showError('Import failed: ' + (error as any).message); + showError('Import failed: ' + getErrorMessage(error)); }, } ); diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 18a5216..2479e82 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -2,6 +2,7 @@ import { useState, FormEvent } from 'react'; import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1'; import { Button } from '../components/Button'; import { useToasts } from '../components/ToastContext'; +import { getErrorMessage } from '../utils/errors'; interface BackupTypes { covers: boolean; @@ -43,10 +44,10 @@ export default function AdminPage() { // Stream the response directly to disk using File System Access API // This avoids loading multi-GB files into browser memory - if (typeof (window as any).showSaveFilePicker === 'function') { + if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') { try { // Modern browsers: Use File System Access API for direct disk writes - const handle = await (window as any).showSaveFilePicker({ + const handle = await window.showSaveFilePicker({ suggestedName: filename, types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }], }); @@ -78,7 +79,7 @@ export default function AdminPage() { ); } } catch (error) { - showError('Backup failed: ' + (error as any).message); + showError('Backup failed: ' + getErrorMessage(error)); } }; @@ -98,7 +99,7 @@ export default function AdminPage() { showInfo('Restore completed successfully'); }, onError: error => { - showError('Restore failed: ' + (error as any).message); + showError('Restore failed: ' + getErrorMessage(error)); }, } ); @@ -116,7 +117,7 @@ export default function AdminPage() { showInfo('Metadata matching started'); }, onError: error => { - showError('Metadata matching failed: ' + (error as any).message); + showError('Metadata matching failed: ' + getErrorMessage(error)); }, } ); @@ -134,7 +135,7 @@ export default function AdminPage() { showInfo('Cache tables started'); }, onError: error => { - showError('Cache tables failed: ' + (error as any).message); + showError('Cache tables failed: ' + getErrorMessage(error)); }, } ); diff --git a/frontend/src/pages/AdminUsersPage.tsx b/frontend/src/pages/AdminUsersPage.tsx index 1808a38..de02c47 100644 --- a/frontend/src/pages/AdminUsersPage.tsx +++ b/frontend/src/pages/AdminUsersPage.tsx @@ -2,6 +2,7 @@ import { useState, FormEvent } from 'react'; import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1'; import { AddIcon, DeleteIcon } from '../icons'; import { useToasts } from '../components/ToastContext'; +import { getErrorMessage } from '../utils/errors'; export default function AdminUsersPage() { const { data: usersData, isLoading, refetch } = useGetUsers({}); @@ -37,8 +38,8 @@ export default function AdminUsersPage() { setNewIsAdmin(false); refetch(); }, - onError: (error: any) => { - showError('Failed to create user: ' + error.message); + onError: error => { + showError('Failed to create user: ' + getErrorMessage(error)); }, } ); @@ -57,8 +58,8 @@ export default function AdminUsersPage() { showInfo('User deleted successfully'); refetch(); }, - onError: (error: any) => { - showError('Failed to delete user: ' + error.message); + onError: error => { + showError('Failed to delete user: ' + getErrorMessage(error)); }, } ); @@ -80,8 +81,8 @@ export default function AdminUsersPage() { showInfo('Password updated successfully'); refetch(); }, - onError: (error: any) => { - showError('Failed to update password: ' + error.message); + onError: error => { + showError('Failed to update password: ' + getErrorMessage(error)); }, } ); @@ -102,8 +103,8 @@ export default function AdminUsersPage() { showInfo(`User permissions updated to ${role}`); refetch(); }, - onError: (error: any) => { - showError('Failed to update admin status: ' + error.message); + onError: error => { + showError('Failed to update admin status: ' + getErrorMessage(error)); }, } ); diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index 65a22cb..58a07c3 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -1,25 +1,16 @@ import { useState, FormEvent, useRef, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'; -import type { DocumentsResponse } from '../generated/model/documentsResponse'; +import type { Document, DocumentsResponse } from '../generated/model'; import { ActivityIcon, DownloadIcon, SearchIcon, UploadIcon } from '../icons'; import { Button } from '../components/Button'; import { useToasts } from '../components/ToastContext'; import { formatDuration } from '../utils/formatters'; import { useDebounce } from '../hooks/useDebounce'; +import { getErrorMessage } from '../utils/errors'; interface DocumentCardProps { - doc: { - id: string; - title: string; - author: string; - created_at: string; - deleted: boolean; - words?: number; - filepath?: string; - percentage?: number; - total_time_seconds?: number; - }; + doc: Document; } function DocumentCard({ doc }: DocumentCardProps) { @@ -125,8 +116,8 @@ export default function DocumentsPage() { showInfo('Document uploaded successfully!'); setUploadMode(false); refetch(); - } catch (error: any) { - showError('Failed to upload document: ' + error.message); + } catch (error) { + showError('Failed to upload document: ' + getErrorMessage(error)); } }; @@ -170,7 +161,7 @@ export default function DocumentsPage() { {isLoading ? (
Loading...
) : ( - docs?.map((doc: any) => ) + docs?.map(doc => ) )}
@@ -220,7 +211,9 @@ export default function DocumentsPage() { type="submit" onClick={e => { e.preventDefault(); - handleFileChange({ target: { files: fileInputRef.current?.files } } as any); + handleFileChange({ + target: { files: fileInputRef.current?.files }, + } as React.ChangeEvent); }} > Upload File diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index d25c032..f2a7e03 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,7 +1,12 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { useGetHome } from '../generated/anthoLumeAPIV1'; -import type { LeaderboardData } from '../generated/model'; +import type { + HomeResponse, + LeaderboardData, + LeaderboardEntry, + UserStreak, +} from '../generated/model'; import ReadingHistoryGraph from '../components/ReadingHistoryGraph'; import { formatNumber, formatDuration } from '../utils/formatters'; @@ -127,7 +132,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {

{name} Leaderboard

-
+
- {currentData?.slice(0, 3).map((item: any, index: number) => ( + {currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => (
0 ? 'border-t border-gray-200' : ''}`} @@ -192,10 +197,11 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) { export default function HomePage() { const { data: homeData, isLoading: homeLoading } = useGetHome(); - const dbInfo = homeData?.data?.database_info; - const streaks = homeData?.data?.streaks?.streaks; - const graphData = homeData?.data?.graph_data?.graph_data; - const userStats = homeData?.data?.user_statistics; + const homeResponse = homeData?.status === 200 ? (homeData.data as HomeResponse) : null; + const dbInfo = homeResponse?.database_info; + const streaks = homeResponse?.streaks?.streaks; + const graphData = homeResponse?.graph_data?.graph_data; + const userStats = homeResponse?.user_statistics; if (homeLoading) { return
Loading...
; @@ -223,7 +229,7 @@ export default function HomePage() { {/* Streak Cards */}
- {streaks?.map((streak: any, index) => ( + {streaks?.map((streak: UserStreak, index: number) => ( { if (!isCheckingAuth && isAuthenticated) { navigate('/', { replace: true }); @@ -76,7 +86,15 @@ export default function LoginPage() {
-

+ {registrationEnabled && ( +

+ Don't have an account?{' '} + + Register here. + +

+ )} +

Offline / Local Mode @@ -84,7 +102,7 @@ export default function LoginPage() {

-
+
AnthoLume
diff --git a/frontend/src/pages/ProgressPage.tsx b/frontend/src/pages/ProgressPage.tsx index 59b8cb7..4406976 100644 --- a/frontend/src/pages/ProgressPage.tsx +++ b/frontend/src/pages/ProgressPage.tsx @@ -1,16 +1,17 @@ import { Link } from 'react-router-dom'; import { useGetProgressList } from '../generated/anthoLumeAPIV1'; +import type { Progress } from '../generated/model'; import { Table } from '../components/Table'; export default function ProgressPage() { const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 }); - const progress = data?.data?.progress; + const progress = data?.status === 200 ? (data.data.progress ?? []) : []; const columns = [ { key: 'document_id' as const, header: 'Document', - render: (_: any, row: any) => ( + render: (_value: Progress['document_id'], row: Progress) => ( value || 'Unknown', + render: (value: Progress['device_name']) => value || 'Unknown', }, { key: 'percentage' as const, header: 'Percentage', - render: (value: any) => (value ? `${Math.round(value)}%` : '0%'), + render: (value: Progress['percentage']) => (value ? `${Math.round(value)}%` : '0%'), }, { key: 'created_at' as const, header: 'Created At', - render: (value: any) => (value ? new Date(value).toLocaleDateString() : 'N/A'), + render: (value: Progress['created_at']) => + value ? new Date(value).toLocaleDateString() : 'N/A', }, ]; diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..6f5f4ab --- /dev/null +++ b/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,116 @@ +import { useState, FormEvent, useEffect } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuth } from '../auth/AuthContext'; +import { Button } from '../components/Button'; +import { useToasts } from '../components/ToastContext'; +import { useGetInfo } from '../generated/anthoLumeAPIV1'; + +export default function RegisterPage() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const { register, isAuthenticated, isCheckingAuth } = useAuth(); + const navigate = useNavigate(); + const { showError } = useToasts(); + const { data: infoData, isLoading: isLoadingInfo } = useGetInfo({ + query: { + staleTime: Infinity, + }, + }); + + const registrationEnabled = + infoData && 'data' in infoData && infoData.data && 'registration_enabled' in infoData.data + ? infoData.data.registration_enabled + : false; + + useEffect(() => { + if (!isCheckingAuth && isAuthenticated) { + navigate('/', { replace: true }); + return; + } + + if (!isLoadingInfo && !registrationEnabled) { + navigate('/login', { replace: true }); + } + }, [isAuthenticated, isCheckingAuth, isLoadingInfo, navigate, registrationEnabled]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + await register(username, password); + } catch (_err) { + showError(registrationEnabled ? 'Registration failed' : 'Registration is disabled'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+

Welcome.

+
+
+
+ setUsername(e.target.value)} + className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600" + placeholder="Username" + required + disabled={isLoading || isLoadingInfo || !registrationEnabled} + /> +
+
+
+
+ setPassword(e.target.value)} + className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600" + placeholder="Password" + required + disabled={isLoading || isLoadingInfo || !registrationEnabled} + /> +
+
+ +
+
+

+ Trying to login?{' '} + + Login here. + +

+

+ + Offline / Local Mode + +

+
+
+
+
+
+ AnthoLume +
+
+
+
+ ); +} diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index 37472a3..f68f5e6 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -1,6 +1,7 @@ import { useState, FormEvent } from 'react'; import { useGetSearch } from '../generated/anthoLumeAPIV1'; import { GetSearchSource } from '../generated/model/getSearchSource'; +import type { SearchItem } from '../generated/model'; import { SearchIcon, DownloadIcon, BookIcon } from '../icons'; import { Button } from '../components/Button'; @@ -9,7 +10,7 @@ export default function SearchPage() { const [source, setSource] = useState(GetSearchSource.LibGen); const { data, isLoading } = useGetSearch({ query, source }); - const results = data?.data?.results; + const results = data?.status === 200 ? data.data.results : []; const handleSubmit = (e: FormEvent) => { e.preventDefault(); @@ -97,7 +98,7 @@ export default function SearchPage() { )} {!isLoading && results && - results.map((item: any) => ( + results.map((item: SearchItem) => (