This commit is contained in:
2026-03-22 10:44:24 -04:00
parent 7e96e41ba4
commit 27e651c4f5
25 changed files with 774 additions and 225 deletions

View File

@@ -538,6 +538,9 @@ type UpdateUserFormdataRequestBody UpdateUserFormdataBody
// LoginJSONRequestBody defines body for Login for application/json ContentType. // LoginJSONRequestBody defines body for Login for application/json ContentType.
type LoginJSONRequestBody = LoginRequest 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. // CreateDocumentMultipartRequestBody defines body for CreateDocument for multipart/form-data ContentType.
type CreateDocumentMultipartRequestBody CreateDocumentMultipartBody type CreateDocumentMultipartRequestBody CreateDocumentMultipartBody
@@ -591,6 +594,9 @@ type ServerInterface interface {
// Get current user info // Get current user info
// (GET /auth/me) // (GET /auth/me)
GetMe(w http.ResponseWriter, r *http.Request) GetMe(w http.ResponseWriter, r *http.Request)
// User registration
// (POST /auth/register)
Register(w http.ResponseWriter, r *http.Request)
// List documents // List documents
// (GET /documents) // (GET /documents)
GetDocuments(w http.ResponseWriter, r *http.Request, params GetDocumentsParams) 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) 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 // GetDocuments operation middleware
func (siw *ServerInterfaceWrapper) GetDocuments(w http.ResponseWriter, r *http.Request) { 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/login", wrapper.Login)
m.HandleFunc("POST "+options.BaseURL+"/auth/logout", wrapper.Logout) m.HandleFunc("POST "+options.BaseURL+"/auth/logout", wrapper.Logout)
m.HandleFunc("GET "+options.BaseURL+"/auth/me", wrapper.GetMe) 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("GET "+options.BaseURL+"/documents", wrapper.GetDocuments)
m.HandleFunc("POST "+options.BaseURL+"/documents", wrapper.CreateDocument) m.HandleFunc("POST "+options.BaseURL+"/documents", wrapper.CreateDocument)
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}", wrapper.GetDocument) 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) 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 { type GetDocumentsRequestObject struct {
Params GetDocumentsParams Params GetDocumentsParams
} }
@@ -2864,6 +2929,9 @@ type StrictServerInterface interface {
// Get current user info // Get current user info
// (GET /auth/me) // (GET /auth/me)
GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error)
// User registration
// (POST /auth/register)
Register(ctx context.Context, request RegisterRequestObject) (RegisterResponseObject, error)
// List documents // List documents
// (GET /documents) // (GET /documents)
GetDocuments(ctx context.Context, request GetDocumentsRequestObject) (GetDocumentsResponseObject, error) 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 // GetDocuments operation middleware
func (sh *strictHandler) GetDocuments(w http.ResponseWriter, r *http.Request, params GetDocumentsParams) { func (sh *strictHandler) GetDocuments(w http.ResponseWriter, r *http.Request, params GetDocumentsParams) {
var request GetDocumentsRequestObject var request GetDocumentsRequestObject

View File

@@ -36,44 +36,8 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
return Login401JSONResponse{Code: 401, Message: "Invalid credentials"}, nil return Login401JSONResponse{Code: 401, Message: "Invalid credentials"}, nil
} }
// Get request and response from context (set by middleware) if err := s.saveUserSession(ctx, user.ID, user.Admin, *user.AuthHash); err != nil {
r := s.getRequestFromContext(ctx) return Login500JSONResponse{Code: 500, Message: err.Error()}, nil
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
} }
return Login200JSONResponse{ return Login200JSONResponse{
@@ -82,6 +46,46 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
}, nil }, 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 // POST /auth/logout
func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) { func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) {
_, ok := s.getSessionFromContext(ctx) _, 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 return Logout401JSONResponse{Code: 401, Message: "Internal context error"}, nil
} }
// Create session store session, err := s.getCookieSession(r)
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 { if err != nil {
return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, 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) session.Values = make(map[any]any)
if err := session.Save(r, w); err != nil { if err := session.Save(r, w); err != nil {
@@ -140,6 +127,50 @@ func (s *Server) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeRe
}, nil }, 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 // getSessionFromContext extracts authData from context
func (s *Server) getSessionFromContext(ctx context.Context) (authData, bool) { func (s *Server) getSessionFromContext(ctx context.Context) (authData, bool) {
auth, ok := ctx.Value("auth").(authData) auth, ok := ctx.Value("auth").(authData)

View File

@@ -126,6 +126,51 @@ func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() {
suite.Equal(http.StatusUnauthorized, w.Code) 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() { func (suite *AuthTestSuite) TestAPILogout() {
suite.createTestUser("testuser", "testpass") suite.createTestUser("testuser", "testpass")
cookie := suite.login("testuser", "testpass") cookie := suite.login("testuser", "testpass")

View File

@@ -11,9 +11,9 @@ import (
"strings" "strings"
"time" "time"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database" "reichard.io/antholume/database"
"reichard.io/antholume/metadata" "reichard.io/antholume/metadata"
log "github.com/sirupsen/logrus"
) )
// GET /documents // GET /documents
@@ -306,7 +306,7 @@ func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
} }
// parseInterfaceTime converts an interface{} to time.Time for SQLC queries // 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 { if t == nil {
return nil return nil
} }

View File

@@ -1182,6 +1182,44 @@ paths:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $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: /auth/logout:
post: post:
summary: User logout summary: User logout

View File

@@ -46,8 +46,8 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
ctx = context.WithValue(ctx, "request", r) ctx = context.WithValue(ctx, "request", r)
ctx = context.WithValue(ctx, "response", w) ctx = context.WithValue(ctx, "response", w)
// Skip auth for login and info endpoints - cover and file require auth via cookies // Skip auth for public auth and info endpoints - cover and file require auth via cookies
if operationID == "Login" || operationID == "GetInfo" { if operationID == "Login" || operationID == "Register" || operationID == "GetInfo" {
return handler(ctx, w, r, request) return handler(ctx, w, r, request)
} }
@@ -97,5 +97,3 @@ func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (Get
RegistrationEnabled: s.cfg.RegistrationEnabled, RegistrationEnabled: s.cfg.RegistrationEnabled,
}, nil }, nil
} }

View File

@@ -8,6 +8,7 @@ import ActivityPage from './pages/ActivityPage';
import SearchPage from './pages/SearchPage'; import SearchPage from './pages/SearchPage';
import SettingsPage from './pages/SettingsPage'; import SettingsPage from './pages/SettingsPage';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import AdminPage from './pages/AdminPage'; import AdminPage from './pages/AdminPage';
import AdminImportPage from './pages/AdminImportPage'; import AdminImportPage from './pages/AdminImportPage';
import AdminImportResultsPage from './pages/AdminImportResultsPage'; import AdminImportResultsPage from './pages/AdminImportResultsPage';
@@ -118,6 +119,7 @@ export function Routes() {
/> />
</Route> </Route>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
</ReactRoutes> </ReactRoutes>
); );
} }

View File

@@ -1,6 +1,13 @@
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useLogin, useLogout, useGetMe } from '../generated/anthoLumeAPIV1'; import {
getGetMeQueryKey,
useLogin,
useLogout,
useGetMe,
useRegister,
} from '../generated/anthoLumeAPIV1';
interface AuthState { interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -10,6 +17,7 @@ interface AuthState {
interface AuthContextType extends AuthState { interface AuthContextType extends AuthState {
login: (_username: string, _password: string) => Promise<void>; login: (_username: string, _password: string) => Promise<void>;
register: (_username: string, _password: string) => Promise<void>;
logout: () => void; logout: () => void;
} }
@@ -19,27 +27,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [authState, setAuthState] = useState<AuthState>({ const [authState, setAuthState] = useState<AuthState>({
isAuthenticated: false, isAuthenticated: false,
user: null, user: null,
isCheckingAuth: true, // Start with checking state to prevent redirects during initial load isCheckingAuth: true,
}); });
const loginMutation = useLogin(); const loginMutation = useLogin();
const registerMutation = useRegister();
const logoutMutation = useLogout(); const logoutMutation = useLogout();
// Always call /me to check authentication status
const { data: meData, error: meError, isLoading: meLoading } = useGetMe(); const { data: meData, error: meError, isLoading: meLoading } = useGetMe();
const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
// Update auth state based on /me endpoint response
useEffect(() => { useEffect(() => {
setAuthState(prev => { setAuthState(prev => {
if (meLoading) { if (meLoading) {
// Still checking authentication
console.log('[AuthContext] Checking authentication status...');
return { ...prev, isCheckingAuth: true }; return { ...prev, isCheckingAuth: true };
} else if (meData?.data && meData.status === 200) { } 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; const userData = 'username' in meData.data ? meData.data : null;
return { return {
isAuthenticated: true, isAuthenticated: true,
@@ -47,16 +51,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isCheckingAuth: false, isCheckingAuth: false,
}; };
} else if (meError || (meData && meData.status === 401)) { } 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 { return {
isAuthenticated: false, isAuthenticated: false,
user: null, user: null,
isCheckingAuth: false, 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]); }, [meData, meError, meLoading]);
@@ -70,41 +72,92 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, },
}); });
// The backend uses session-based authentication, so no token to store if (response.status !== 200 || !('username' in response.data)) {
// The session cookie is automatically set by the browser
setAuthState({
isAuthenticated: true,
user:
'username' in response.data
? (response.data as { username: string; is_admin: boolean })
: null,
isCheckingAuth: false,
});
navigate('/');
} catch (_error) {
console.error('[AuthContext] Login failed:', _error);
throw new Error('Login failed');
}
},
[loginMutation, navigate]
);
const logout = useCallback(() => {
logoutMutation.mutate(undefined, {
onSuccess: () => {
setAuthState({ setAuthState({
isAuthenticated: false, isAuthenticated: false,
user: null, user: null,
isCheckingAuth: false, isCheckingAuth: false,
}); });
throw new Error('Login 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('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'); navigate('/login');
}, },
}); });
}, [logoutMutation, navigate]); }, [logoutMutation, navigate, queryClient]);
return ( return (
<AuthContext.Provider value={{ ...authState, login, logout }}>{children}</AuthContext.Provider> <AuthContext.Provider value={{ ...authState, login, register, logout }}>
{children}
</AuthContext.Provider>
); );
} }

View File

@@ -9,7 +9,9 @@ export default function Layout() {
const location = useLocation(); const location = useLocation();
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth(); const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
const { data } = useGetMe(isAuthenticated ? {} : undefined); 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 [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@@ -34,15 +36,26 @@ export default function Layout() {
// Get current page title // Get current page title
const navItems = [ 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: '/documents', title: 'Documents' },
{ path: '/progress', title: 'Progress' }, { path: '/progress', title: 'Progress' },
{ path: '/activity', title: 'Activity' }, { path: '/activity', title: 'Activity' },
{ path: '/search', title: 'Search' }, { path: '/search', title: 'Search' },
{ path: '/settings', title: 'Settings' }, { path: '/settings', title: 'Settings' },
{ path: '/', title: 'Home' },
]; ];
const currentPageTitle = 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 // Show loading while checking authentication status
if (isCheckingAuth) { if (isCheckingAuth) {
@@ -62,7 +75,9 @@ export default function Layout() {
<HamburgerMenu /> <HamburgerMenu />
{/* Header Title */} {/* Header Title */}
<h1 className="px-6 text-xl font-bold lg:ml-44 dark:text-white">{currentPageTitle}</h1> <h1 className="whitespace-nowrap px-6 text-xl font-bold lg:ml-44 dark:text-white">
{currentPageTitle}
</h1>
{/* User Dropdown */} {/* User Dropdown */}
<div <div
@@ -78,7 +93,7 @@ export default function Layout() {
{isUserDropdownOpen && ( {isUserDropdownOpen && (
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200"> <div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
<div className="w-40 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 dark:bg-gray-700 dark:shadow-gray-800"> <div className="w-40 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-700 dark:shadow-gray-800">
<div <div
className="py-1" className="py-1"
role="menu" role="menu"

View File

@@ -2,14 +2,14 @@ import React from 'react';
import { Skeleton } from './Skeleton'; import { Skeleton } from './Skeleton';
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
export interface Column<T> { export interface Column<T extends Record<string, unknown>> {
key: keyof T; key: keyof T;
header: string; header: string;
render?: (value: any, _row: T, _index: number) => React.ReactNode; render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode;
className?: string; className?: string;
} }
export interface TableProps<T> { export interface TableProps<T extends Record<string, unknown>> {
columns: Column<T>[]; columns: Column<T>[];
data: T[]; data: T[];
loading?: boolean; loading?: boolean;
@@ -58,7 +58,7 @@ function SkeletonTable({
); );
} }
export function Table<T extends Record<string, any>>({ export function Table<T extends Record<string, unknown>>({
columns, columns,
data, data,
loading = false, loading = false,

View File

@@ -1690,6 +1690,112 @@ export const useLogin = <TError = ErrorResponse,
return useMutation(getLoginMutationOptions(options), queryClient); return useMutation(getLoginMutationOptions(options), queryClient);
} }
/**
* @summary User registration
*/
export type registerResponse201 = {
data: LoginResponse
status: 201
}
export type registerResponse400 = {
data: ErrorResponse
status: 400
}
export type registerResponse403 = {
data: ErrorResponse
status: 403
}
export type registerResponse500 = {
data: ErrorResponse
status: 500
}
export type registerResponseSuccess = (registerResponse201) & {
headers: Headers;
};
export type registerResponseError = (registerResponse400 | registerResponse403 | registerResponse500) & {
headers: Headers;
};
export type registerResponse = (registerResponseSuccess | registerResponseError)
export const getRegisterUrl = () => {
return `/api/v1/auth/register`
}
export const register = async (loginRequest: LoginRequest, options?: RequestInit): Promise<registerResponse> => {
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 = <TError = ErrorResponse,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof register>>, TError,{data: LoginRequest}, TContext>, fetch?: RequestInit}
): UseMutationOptions<Awaited<ReturnType<typeof register>>, 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<Awaited<ReturnType<typeof register>>, {data: LoginRequest}> = (props) => {
const {data} = props ?? {};
return register(data,fetchOptions)
}
return { mutationFn, ...mutationOptions }}
export type RegisterMutationResult = NonNullable<Awaited<ReturnType<typeof register>>>
export type RegisterMutationBody = LoginRequest
export type RegisterMutationError = ErrorResponse
/**
* @summary User registration
*/
export const useRegister = <TError = ErrorResponse,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof register>>, TError,{data: LoginRequest}, TContext>, fetch?: RequestInit}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof register>>,
TError,
{data: LoginRequest},
TContext
> => {
return useMutation(getRegisterMutationOptions(options), queryClient);
}
/** /**
* @summary User logout * @summary User logout
*/ */

View File

@@ -6,7 +6,11 @@ interface FolderOpenIconProps {
disabled?: boolean; disabled?: boolean;
} }
export function FolderOpenIcon({ size = 24, className = '', disabled = false }: FolderOpenIconProps) { export function FolderOpenIcon({
size = 24,
className = '',
disabled = false,
}: FolderOpenIconProps) {
return ( return (
<BaseIcon size={size} className={className} disabled={disabled}> <BaseIcon size={size} className={className} disabled={disabled}>
<path <path

View File

@@ -3,6 +3,10 @@ interface LoadingIconProps {
className?: string; className?: string;
} }
const spinnerAnimation = 'spinner_rcyq 1.2s cubic-bezier(0.52, 0.6, 0.25, 0.99) infinite';
const spinnerPath = 'M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z';
export function LoadingIcon({ size = 24, className = '' }: LoadingIconProps) { export function LoadingIcon({ size = 24, className = '' }: LoadingIconProps) {
return ( return (
<svg <svg
@@ -15,15 +19,6 @@ export function LoadingIcon({ size = 24, className = '' }: LoadingIconProps) {
> >
<style> <style>
{` {`
.spinner_l9ve {
animation: spinner_rcyq 1.2s cubic-bezier(0.52, 0.6, 0.25, 0.99) infinite;
}
.spinner_cMYp {
animation-delay: 0.4s;
}
.spinner_gHR3 {
animation-delay: 0.8s;
}
@keyframes spinner_rcyq { @keyframes spinner_rcyq {
0% { 0% {
transform: translate(12px, 12px) scale(0); transform: translate(12px, 12px) scale(0);
@@ -37,19 +32,19 @@ export function LoadingIcon({ size = 24, className = '' }: LoadingIconProps) {
`} `}
</style> </style>
<path <path
className="spinner_l9ve" d={spinnerPath}
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
transform="translate(12, 12) scale(0)" transform="translate(12, 12) scale(0)"
style={{ animation: spinnerAnimation }}
/> />
<path <path
className="spinner_l9ve spinner_cMYp" d={spinnerPath}
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
transform="translate(12, 12) scale(0)" transform="translate(12, 12) scale(0)"
style={{ animation: spinnerAnimation, animationDelay: '0.4s' }}
/> />
<path <path
className="spinner_l9ve spinner_gHR3" d={spinnerPath}
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"
transform="translate(12, 12) scale(0)" transform="translate(12, 12) scale(0)"
style={{ animation: spinnerAnimation, animationDelay: '0.8s' }}
/> />
</svg> </svg>
); );

View File

@@ -1,17 +1,18 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useGetActivity } from '../generated/anthoLumeAPIV1'; import { useGetActivity } from '../generated/anthoLumeAPIV1';
import type { Activity } from '../generated/model';
import { Table } from '../components/Table'; import { Table } from '../components/Table';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
export default function ActivityPage() { export default function ActivityPage() {
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 }); const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
const activities = data?.data?.activities; const activities = data?.status === 200 ? data.data.activities : [];
const columns = [ const columns = [
{ {
key: 'document_id' as const, key: 'document_id' as const,
header: 'Document', header: 'Document',
render: (_: any, row: any) => ( render: (_value: Activity['document_id'], row: Activity) => (
<Link <Link
to={`/documents/${row.document_id}`} to={`/documents/${row.document_id}`}
className="text-blue-600 hover:underline dark:text-blue-400" className="text-blue-600 hover:underline dark:text-blue-400"
@@ -23,19 +24,19 @@ export default function ActivityPage() {
{ {
key: 'start_time' as const, key: 'start_time' as const,
header: 'Time', header: 'Time',
render: (value: any) => value || 'N/A', render: (value: Activity['start_time']) => value || 'N/A',
}, },
{ {
key: 'duration' as const, key: 'duration' as const,
header: 'Duration', header: 'Duration',
render: (value: any) => { render: (value: Activity['duration']) => {
return formatDuration(value || 0); return formatDuration(value || 0);
}, },
}, },
{ {
key: 'end_percentage' as const, key: 'end_percentage' as const,
header: 'Percent', header: 'Percent',
render: (value: any) => (value != null ? `${value}%` : '0%'), render: (value: Activity['end_percentage']) => (value != null ? `${value}%` : '0%'),
}, },
]; ];

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1'; import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
import { getErrorMessage } from '../utils/errors';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { FolderOpenIcon } from '../icons'; import { FolderOpenIcon } from '../icons';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
@@ -50,7 +51,7 @@ export default function AdminImportPage() {
}, 1500); }, 1500);
}, },
onError: error => { onError: error => {
showError('Import failed: ' + (error as any).message); showError('Import failed: ' + getErrorMessage(error));
}, },
} }
); );

View File

@@ -2,6 +2,7 @@ import { useState, FormEvent } from 'react';
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1'; import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors';
interface BackupTypes { interface BackupTypes {
covers: boolean; covers: boolean;
@@ -43,10 +44,10 @@ export default function AdminPage() {
// Stream the response directly to disk using File System Access API // Stream the response directly to disk using File System Access API
// This avoids loading multi-GB files into browser memory // 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 { try {
// Modern browsers: Use File System Access API for direct disk writes // 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, suggestedName: filename,
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }], types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
}); });
@@ -78,7 +79,7 @@ export default function AdminPage() {
); );
} }
} catch (error) { } 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'); showInfo('Restore completed successfully');
}, },
onError: error => { 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'); showInfo('Metadata matching started');
}, },
onError: error => { 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'); showInfo('Cache tables started');
}, },
onError: error => { onError: error => {
showError('Cache tables failed: ' + (error as any).message); showError('Cache tables failed: ' + getErrorMessage(error));
}, },
} }
); );

View File

@@ -2,6 +2,7 @@ import { useState, FormEvent } from 'react';
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1'; import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
import { AddIcon, DeleteIcon } from '../icons'; import { AddIcon, DeleteIcon } from '../icons';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors';
export default function AdminUsersPage() { export default function AdminUsersPage() {
const { data: usersData, isLoading, refetch } = useGetUsers({}); const { data: usersData, isLoading, refetch } = useGetUsers({});
@@ -37,8 +38,8 @@ export default function AdminUsersPage() {
setNewIsAdmin(false); setNewIsAdmin(false);
refetch(); refetch();
}, },
onError: (error: any) => { onError: error => {
showError('Failed to create user: ' + error.message); showError('Failed to create user: ' + getErrorMessage(error));
}, },
} }
); );
@@ -57,8 +58,8 @@ export default function AdminUsersPage() {
showInfo('User deleted successfully'); showInfo('User deleted successfully');
refetch(); refetch();
}, },
onError: (error: any) => { onError: error => {
showError('Failed to delete user: ' + error.message); showError('Failed to delete user: ' + getErrorMessage(error));
}, },
} }
); );
@@ -80,8 +81,8 @@ export default function AdminUsersPage() {
showInfo('Password updated successfully'); showInfo('Password updated successfully');
refetch(); refetch();
}, },
onError: (error: any) => { onError: error => {
showError('Failed to update password: ' + error.message); showError('Failed to update password: ' + getErrorMessage(error));
}, },
} }
); );
@@ -102,8 +103,8 @@ export default function AdminUsersPage() {
showInfo(`User permissions updated to ${role}`); showInfo(`User permissions updated to ${role}`);
refetch(); refetch();
}, },
onError: (error: any) => { onError: error => {
showError('Failed to update admin status: ' + error.message); showError('Failed to update admin status: ' + getErrorMessage(error));
}, },
} }
); );

View File

@@ -1,25 +1,16 @@
import { useState, FormEvent, useRef, useEffect } from 'react'; import { useState, FormEvent, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1'; 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 { ActivityIcon, DownloadIcon, SearchIcon, UploadIcon } from '../icons';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { useDebounce } from '../hooks/useDebounce'; import { useDebounce } from '../hooks/useDebounce';
import { getErrorMessage } from '../utils/errors';
interface DocumentCardProps { interface DocumentCardProps {
doc: { doc: Document;
id: string;
title: string;
author: string;
created_at: string;
deleted: boolean;
words?: number;
filepath?: string;
percentage?: number;
total_time_seconds?: number;
};
} }
function DocumentCard({ doc }: DocumentCardProps) { function DocumentCard({ doc }: DocumentCardProps) {
@@ -125,8 +116,8 @@ export default function DocumentsPage() {
showInfo('Document uploaded successfully!'); showInfo('Document uploaded successfully!');
setUploadMode(false); setUploadMode(false);
refetch(); refetch();
} catch (error: any) { } catch (error) {
showError('Failed to upload document: ' + error.message); showError('Failed to upload document: ' + getErrorMessage(error));
} }
}; };
@@ -170,7 +161,7 @@ export default function DocumentsPage() {
{isLoading ? ( {isLoading ? (
<div className="col-span-full text-center text-gray-500 dark:text-white">Loading...</div> <div className="col-span-full text-center text-gray-500 dark:text-white">Loading...</div>
) : ( ) : (
docs?.map((doc: any) => <DocumentCard key={doc.id} doc={doc} />) docs?.map(doc => <DocumentCard key={doc.id} doc={doc} />)
)} )}
</div> </div>
@@ -220,7 +211,9 @@ export default function DocumentsPage() {
type="submit" type="submit"
onClick={e => { onClick={e => {
e.preventDefault(); e.preventDefault();
handleFileChange({ target: { files: fileInputRef.current?.files } } as any); handleFileChange({
target: { files: fileInputRef.current?.files },
} as React.ChangeEvent<HTMLInputElement>);
}} }}
> >
Upload File Upload File

View File

@@ -1,7 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useGetHome } from '../generated/anthoLumeAPIV1'; 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 ReadingHistoryGraph from '../components/ReadingHistoryGraph';
import { formatNumber, formatDuration } from '../utils/formatters'; import { formatNumber, formatDuration } from '../utils/formatters';
@@ -127,7 +132,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
<p className="w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white"> <p className="w-max border-b border-gray-200 text-sm font-semibold text-gray-700 dark:border-gray-500 dark:text-white">
{name} Leaderboard {name} Leaderboard
</p> </p>
<div className="flex gap-2 text-xs text-gray-400 items-center"> <div className="flex items-center gap-2 text-xs text-gray-400">
<button <button
type="button" type="button"
onClick={() => handlePeriodChange('all')} onClick={() => handlePeriodChange('all')}
@@ -172,7 +177,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
</div> </div>
<div className="dark:text-white"> <div className="dark:text-white">
{currentData?.slice(0, 3).map((item: any, index: number) => ( {currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => (
<div <div
key={index} key={index}
className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-gray-200' : ''}`} className={`flex items-center justify-between py-2 text-sm ${index > 0 ? 'border-t border-gray-200' : ''}`}
@@ -192,10 +197,11 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
export default function HomePage() { export default function HomePage() {
const { data: homeData, isLoading: homeLoading } = useGetHome(); const { data: homeData, isLoading: homeLoading } = useGetHome();
const dbInfo = homeData?.data?.database_info; const homeResponse = homeData?.status === 200 ? (homeData.data as HomeResponse) : null;
const streaks = homeData?.data?.streaks?.streaks; const dbInfo = homeResponse?.database_info;
const graphData = homeData?.data?.graph_data?.graph_data; const streaks = homeResponse?.streaks?.streaks;
const userStats = homeData?.data?.user_statistics; const graphData = homeResponse?.graph_data?.graph_data;
const userStats = homeResponse?.user_statistics;
if (homeLoading) { if (homeLoading) {
return <div className="text-gray-500 dark:text-white">Loading...</div>; return <div className="text-gray-500 dark:text-white">Loading...</div>;
@@ -223,7 +229,7 @@ export default function HomePage() {
{/* Streak Cards */} {/* Streak Cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{streaks?.map((streak: any, index) => ( {streaks?.map((streak: UserStreak, index: number) => (
<StreakCard <StreakCard
key={index} key={index}
window={streak.window as 'DAY' | 'WEEK'} window={streak.window as 'DAY' | 'WEEK'}

View File

@@ -1,8 +1,9 @@
import { useState, FormEvent, useEffect } from 'react'; import { useState, FormEvent, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { useGetInfo } from '../generated/anthoLumeAPIV1';
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -12,8 +13,17 @@ export default function LoginPage() {
const { login, isAuthenticated, isCheckingAuth } = useAuth(); const { login, isAuthenticated, isCheckingAuth } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { showError } = useToasts(); const { showError } = useToasts();
const { data: infoData } = useGetInfo({
query: {
staleTime: Infinity,
},
});
const registrationEnabled =
infoData && 'data' in infoData && infoData.data && 'registration_enabled' in infoData.data
? infoData.data.registration_enabled
: false;
// Redirect to home if already logged in
useEffect(() => { useEffect(() => {
if (!isCheckingAuth && isAuthenticated) { if (!isCheckingAuth && isAuthenticated) {
navigate('/', { replace: true }); navigate('/', { replace: true });
@@ -76,7 +86,15 @@ export default function LoginPage() {
</Button> </Button>
</form> </form>
<div className="py-12 text-center"> <div className="py-12 text-center">
<p className="mt-4"> {registrationEnabled && (
<p>
Don&apos;t have an account?{' '}
<Link to="/register" className="font-semibold underline">
Register here.
</Link>
</p>
)}
<p className={registrationEnabled ? 'mt-4' : ''}>
<a href="/local" className="font-semibold underline"> <a href="/local" className="font-semibold underline">
Offline / Local Mode Offline / Local Mode
</a> </a>
@@ -84,7 +102,7 @@ export default function LoginPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="image-fader relative hidden h-screen w-1/2 shadow-2xl md:block"> <div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-gray-300 object-cover ease-in-out"> <div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-gray-300 object-cover ease-in-out">
<span className="text-gray-500">AnthoLume</span> <span className="text-gray-500">AnthoLume</span>
</div> </div>

View File

@@ -1,16 +1,17 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useGetProgressList } from '../generated/anthoLumeAPIV1'; import { useGetProgressList } from '../generated/anthoLumeAPIV1';
import type { Progress } from '../generated/model';
import { Table } from '../components/Table'; import { Table } from '../components/Table';
export default function ProgressPage() { export default function ProgressPage() {
const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 }); const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 });
const progress = data?.data?.progress; const progress = data?.status === 200 ? (data.data.progress ?? []) : [];
const columns = [ const columns = [
{ {
key: 'document_id' as const, key: 'document_id' as const,
header: 'Document', header: 'Document',
render: (_: any, row: any) => ( render: (_value: Progress['document_id'], row: Progress) => (
<Link <Link
to={`/documents/${row.document_id}`} to={`/documents/${row.document_id}`}
className="text-blue-600 hover:underline dark:text-blue-400" className="text-blue-600 hover:underline dark:text-blue-400"
@@ -22,17 +23,18 @@ export default function ProgressPage() {
{ {
key: 'device_name' as const, key: 'device_name' as const,
header: 'Device Name', header: 'Device Name',
render: (value: any) => value || 'Unknown', render: (value: Progress['device_name']) => value || 'Unknown',
}, },
{ {
key: 'percentage' as const, key: 'percentage' as const,
header: 'Percentage', header: 'Percentage',
render: (value: any) => (value ? `${Math.round(value)}%` : '0%'), render: (value: Progress['percentage']) => (value ? `${Math.round(value)}%` : '0%'),
}, },
{ {
key: 'created_at' as const, key: 'created_at' as const,
header: 'Created At', 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',
}, },
]; ];

View File

@@ -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 (
<div className="min-h-screen bg-gray-100 dark:bg-gray-800 dark:text-white">
<div className="flex w-full flex-wrap">
<div className="flex w-full flex-col md:w-1/2">
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
<p className="text-center text-3xl">Welcome.</p>
<form className="flex flex-col pt-3 md:pt-8" onSubmit={handleSubmit}>
<div className="flex flex-col pt-4">
<div className="relative flex">
<input
type="text"
value={username}
onChange={e => 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}
/>
</div>
</div>
<div className="mb-12 flex flex-col pt-4">
<div className="relative flex">
<input
type="password"
value={password}
onChange={e => 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}
/>
</div>
</div>
<Button
variant="secondary"
type="submit"
disabled={isLoading || isLoadingInfo || !registrationEnabled}
className="w-full px-4 py-2 text-center text-base font-semibold transition duration-200 ease-in focus:outline-none focus:ring-2 disabled:opacity-50"
>
{isLoading ? 'Registering...' : 'Register'}
</Button>
</form>
<div className="py-12 text-center">
<p>
Trying to login?{' '}
<Link to="/login" className="font-semibold underline">
Login here.
</Link>
</p>
<p className="mt-4">
<a href="/local" className="font-semibold underline">
Offline / Local Mode
</a>
</p>
</div>
</div>
</div>
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-gray-300 object-cover ease-in-out">
<span className="text-gray-500">AnthoLume</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import { useGetSearch } from '../generated/anthoLumeAPIV1'; import { useGetSearch } from '../generated/anthoLumeAPIV1';
import { GetSearchSource } from '../generated/model/getSearchSource'; import { GetSearchSource } from '../generated/model/getSearchSource';
import type { SearchItem } from '../generated/model';
import { SearchIcon, DownloadIcon, BookIcon } from '../icons'; import { SearchIcon, DownloadIcon, BookIcon } from '../icons';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
@@ -9,7 +10,7 @@ export default function SearchPage() {
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen); const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
const { data, isLoading } = useGetSearch({ query, source }); const { data, isLoading } = useGetSearch({ query, source });
const results = data?.data?.results; const results = data?.status === 200 ? data.data.results : [];
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -97,7 +98,7 @@ export default function SearchPage() {
)} )}
{!isLoading && {!isLoading &&
results && results &&
results.map((item: any) => ( results.map((item: SearchItem) => (
<tr key={item.id}> <tr key={item.id}>
<td className="border-b border-gray-200 p-3 text-gray-500 dark:text-gray-500"> <td className="border-b border-gray-200 p-3 text-gray-500 dark:text-gray-500">
<button className="hover:text-purple-600" title="Download"> <button className="hover:text-purple-600" title="Download">

View File

@@ -1,13 +1,15 @@
import { useState, useEffect, FormEvent } from 'react'; import { useState, useEffect, FormEvent } from 'react';
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1'; import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
import type { Device, SettingsResponse } from '../generated/model';
import { UserIcon, PasswordIcon, ClockIcon } from '../icons'; import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors';
export default function SettingsPage() { export default function SettingsPage() {
const { data, isLoading } = useGetSettings(); const { data, isLoading } = useGetSettings();
const updateSettings = useUpdateSettings(); const updateSettings = useUpdateSettings();
const settingsData = data; const settingsData = data?.status === 200 ? (data.data as SettingsResponse) : null;
const { showInfo, showError } = useToasts(); const { showInfo, showError } = useToasts();
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -15,8 +17,8 @@ export default function SettingsPage() {
const [timezone, setTimezone] = useState('UTC'); const [timezone, setTimezone] = useState('UTC');
useEffect(() => { useEffect(() => {
if (settingsData?.data.timezone && settingsData.data.timezone.trim() !== '') { if (settingsData?.timezone && settingsData.timezone.trim() !== '') {
setTimezone(settingsData.data.timezone); setTimezone(settingsData.timezone);
} }
}, [settingsData]); }, [settingsData]);
@@ -38,11 +40,8 @@ export default function SettingsPage() {
showInfo('Password updated successfully'); showInfo('Password updated successfully');
setPassword(''); setPassword('');
setNewPassword(''); setNewPassword('');
} catch (error: any) { } catch (error) {
showError( showError('Failed to update password: ' + getErrorMessage(error));
'Failed to update password: ' +
(error.response?.data?.message || error.message || 'Unknown error')
);
} }
}; };
@@ -56,11 +55,8 @@ export default function SettingsPage() {
}, },
}); });
showInfo('Timezone updated successfully'); showInfo('Timezone updated successfully');
} catch (error: any) { } catch (error) {
showError( showError('Failed to update timezone: ' + getErrorMessage(error));
'Failed to update timezone: ' +
(error.response?.data?.message || error.message || 'Unknown error')
);
} }
}; };
@@ -109,7 +105,7 @@ export default function SettingsPage() {
<div> <div>
<div className="flex flex-col items-center rounded bg-white p-4 text-gray-500 shadow-lg md:w-60 lg:w-80 dark:bg-gray-700 dark:text-white"> <div className="flex flex-col items-center rounded bg-white p-4 text-gray-500 shadow-lg md:w-60 lg:w-80 dark:bg-gray-700 dark:text-white">
<UserIcon size={60} /> <UserIcon size={60} />
<p className="text-lg">{settingsData?.data.user.username || 'N/A'}</p> <p className="text-lg">{settingsData?.user.username || 'N/A'}</p>
</div> </div>
</div> </div>
@@ -205,14 +201,14 @@ export default function SettingsPage() {
</tr> </tr>
</thead> </thead>
<tbody className="text-black dark:text-white"> <tbody className="text-black dark:text-white">
{!settingsData?.data.devices || settingsData.data.devices.length === 0 ? ( {!settingsData?.devices || settingsData.devices.length === 0 ? (
<tr> <tr>
<td className="p-3 text-center" colSpan={3}> <td className="p-3 text-center" colSpan={3}>
No Results No Results
</td> </td>
</tr> </tr>
) : ( ) : (
settingsData.data.devices.map((device: any) => ( settingsData.devices.map((device: Device) => (
<tr key={device.id}> <tr key={device.id}>
<td className="p-3 pl-0"> <td className="p-3 pl-0">
<p>{device.device_name || 'Unknown'}</p> <p>{device.device_name || 'Unknown'}</p>

View File

@@ -0,0 +1,27 @@
export function getErrorMessage(error: unknown, fallback = 'Unknown error'): string {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === 'object' && error !== null) {
const errorWithResponse = error as {
message?: unknown;
response?: {
data?: {
message?: unknown;
};
};
};
const responseMessage = errorWithResponse.response?.data?.message;
if (typeof responseMessage === 'string' && responseMessage.trim() !== '') {
return responseMessage;
}
if (typeof errorWithResponse.message === 'string' && errorWithResponse.message.trim() !== '') {
return errorWithResponse.message;
}
}
return fallback;
}