wip 19
This commit is contained in:
@@ -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
|
||||||
|
|||||||
143
api/v1/auth.go
143
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
|
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)
|
||||||
|
|||||||
@@ -25,16 +25,16 @@ type AuthTestSuite struct {
|
|||||||
|
|
||||||
func (suite *AuthTestSuite) setupConfig() *config.Config {
|
func (suite *AuthTestSuite) setupConfig() *config.Config {
|
||||||
return &config.Config{
|
return &config.Config{
|
||||||
ListenPort: "8080",
|
ListenPort: "8080",
|
||||||
DBType: "memory",
|
DBType: "memory",
|
||||||
DBName: "test",
|
DBName: "test",
|
||||||
ConfigPath: "/tmp",
|
ConfigPath: "/tmp",
|
||||||
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
||||||
CookieEncKey: "0123456789abcdef",
|
CookieEncKey: "0123456789abcdef",
|
||||||
CookieSecure: false,
|
CookieSecure: false,
|
||||||
CookieHTTPOnly: true,
|
CookieHTTPOnly: true,
|
||||||
Version: "test",
|
Version: "test",
|
||||||
DemoMode: false,
|
DemoMode: false,
|
||||||
RegistrationEnabled: true,
|
RegistrationEnabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
@@ -163,4 +208,4 @@ func (suite *AuthTestSuite) TestAPIGetMeUnauthenticated() {
|
|||||||
suite.srv.ServeHTTP(w, req)
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
suite.Equal(http.StatusUnauthorized, w.Code)
|
suite.Equal(http.StatusUnauthorized, w.Code)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -81,7 +81,7 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
|||||||
LastRead: parseInterfaceTime(row.LastRead),
|
LastRead: parseInterfaceTime(row.LastRead),
|
||||||
CreatedAt: time.Now(), // Will be overwritten if we had a proper created_at from DB
|
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
|
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 {
|
if row.Words != nil {
|
||||||
wordCounts = append(wordCounts, WordCount{
|
wordCounts = append(wordCounts, WordCount{
|
||||||
@@ -217,10 +217,10 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
|
|||||||
Isbn13: request.Body.Isbn13,
|
Isbn13: request.Body.Isbn13,
|
||||||
Coverfile: coverFileName,
|
Coverfile: coverFileName,
|
||||||
// Preserve existing values for non-editable fields
|
// Preserve existing values for non-editable fields
|
||||||
Md5: currentDoc.Md5,
|
Md5: currentDoc.Md5,
|
||||||
Basepath: currentDoc.Basepath,
|
Basepath: currentDoc.Basepath,
|
||||||
Filepath: currentDoc.Filepath,
|
Filepath: currentDoc.Filepath,
|
||||||
Words: currentDoc.Words,
|
Words: currentDoc.Words,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("UpsertDocument DB Error:", err)
|
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
|
// 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
|
||||||
}
|
}
|
||||||
@@ -380,7 +380,7 @@ func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverR
|
|||||||
} else {
|
} else {
|
||||||
// Derive Path
|
// Derive Path
|
||||||
coverPath := filepath.Join(s.cfg.DataPath, "covers", *document.Coverfile)
|
coverPath := filepath.Join(s.cfg.DataPath, "covers", *document.Coverfile)
|
||||||
|
|
||||||
// Validate File Exists
|
// Validate File Exists
|
||||||
fileInfo, err := os.Stat(coverPath)
|
fileInfo, err := os.Stat(coverPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -713,7 +713,7 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
|
|||||||
}
|
}
|
||||||
|
|
||||||
file := fileField[0]
|
file := fileField[0]
|
||||||
|
|
||||||
// Validate file extension
|
// Validate file extension
|
||||||
if !strings.HasSuffix(strings.ToLower(file.Filename), ".epub") {
|
if !strings.HasSuffix(strings.ToLower(file.Filename), ".epub") {
|
||||||
return CreateDocument400JSONResponse{Code: 400, Message: "Only EPUB files are allowed"}, nil
|
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
|
// Document already exists
|
||||||
existingDoc, _ := s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5)
|
existingDoc, _ := s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5)
|
||||||
apiDoc := Document{
|
apiDoc := Document{
|
||||||
Id: existingDoc.ID,
|
Id: existingDoc.ID,
|
||||||
Title: *existingDoc.Title,
|
Title: *existingDoc.Title,
|
||||||
Author: *existingDoc.Author,
|
Author: *existingDoc.Author,
|
||||||
Description: existingDoc.Description,
|
Description: existingDoc.Description,
|
||||||
Isbn10: existingDoc.Isbn10,
|
Isbn10: existingDoc.Isbn10,
|
||||||
Isbn13: existingDoc.Isbn13,
|
Isbn13: existingDoc.Isbn13,
|
||||||
Words: existingDoc.Words,
|
Words: existingDoc.Words,
|
||||||
Filepath: existingDoc.Filepath,
|
Filepath: existingDoc.Filepath,
|
||||||
CreatedAt: parseTime(existingDoc.CreatedAt),
|
CreatedAt: parseTime(existingDoc.CreatedAt),
|
||||||
UpdatedAt: parseTime(existingDoc.UpdatedAt),
|
UpdatedAt: parseTime(existingDoc.UpdatedAt),
|
||||||
Deleted: existingDoc.Deleted,
|
Deleted: existingDoc.Deleted,
|
||||||
}
|
}
|
||||||
response := DocumentResponse{
|
response := DocumentResponse{
|
||||||
Document: apiDoc,
|
Document: apiDoc,
|
||||||
@@ -818,17 +818,17 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiDoc := Document{
|
apiDoc := Document{
|
||||||
Id: doc.ID,
|
Id: doc.ID,
|
||||||
Title: *doc.Title,
|
Title: *doc.Title,
|
||||||
Author: *doc.Author,
|
Author: *doc.Author,
|
||||||
Description: doc.Description,
|
Description: doc.Description,
|
||||||
Isbn10: doc.Isbn10,
|
Isbn10: doc.Isbn10,
|
||||||
Isbn13: doc.Isbn13,
|
Isbn13: doc.Isbn13,
|
||||||
Words: doc.Words,
|
Words: doc.Words,
|
||||||
Filepath: doc.Filepath,
|
Filepath: doc.Filepath,
|
||||||
CreatedAt: parseTime(doc.CreatedAt),
|
CreatedAt: parseTime(doc.CreatedAt),
|
||||||
UpdatedAt: parseTime(doc.UpdatedAt),
|
UpdatedAt: parseTime(doc.UpdatedAt),
|
||||||
Deleted: doc.Deleted,
|
Deleted: doc.Deleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
response := DocumentResponse{
|
response := DocumentResponse{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +92,8 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
|
|||||||
// GetInfo returns server information
|
// GetInfo returns server information
|
||||||
func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) {
|
func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) {
|
||||||
return GetInfo200JSONResponse{
|
return GetInfo200JSONResponse{
|
||||||
Version: s.cfg.Version,
|
Version: s.cfg.Version,
|
||||||
SearchEnabled: s.cfg.SearchEnabled,
|
SearchEnabled: s.cfg.SearchEnabled,
|
||||||
RegistrationEnabled: s.cfg.RegistrationEnabled,
|
RegistrationEnabled: s.cfg.RegistrationEnabled,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: false,
|
||||||
|
user: null,
|
||||||
|
isCheckingAuth: false,
|
||||||
|
});
|
||||||
|
throw new Error('Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
setAuthState({
|
setAuthState({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
user:
|
user: response.data as { username: string; is_admin: boolean },
|
||||||
'username' in response.data
|
|
||||||
? (response.data as { username: string; is_admin: boolean })
|
|
||||||
: null,
|
|
||||||
isCheckingAuth: false,
|
isCheckingAuth: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (_error) {
|
} 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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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%'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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));
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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));
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
116
frontend/src/pages/RegisterPage.tsx
Normal file
116
frontend/src/pages/RegisterPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
27
frontend/src/utils/errors.ts
Normal file
27
frontend/src/utils/errors.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user