wip 19
This commit is contained in:
@@ -538,6 +538,9 @@ type UpdateUserFormdataRequestBody UpdateUserFormdataBody
|
||||
// LoginJSONRequestBody defines body for Login for application/json ContentType.
|
||||
type LoginJSONRequestBody = LoginRequest
|
||||
|
||||
// RegisterJSONRequestBody defines body for Register for application/json ContentType.
|
||||
type RegisterJSONRequestBody = LoginRequest
|
||||
|
||||
// CreateDocumentMultipartRequestBody defines body for CreateDocument for multipart/form-data ContentType.
|
||||
type CreateDocumentMultipartRequestBody CreateDocumentMultipartBody
|
||||
|
||||
@@ -591,6 +594,9 @@ type ServerInterface interface {
|
||||
// Get current user info
|
||||
// (GET /auth/me)
|
||||
GetMe(w http.ResponseWriter, r *http.Request)
|
||||
// User registration
|
||||
// (POST /auth/register)
|
||||
Register(w http.ResponseWriter, r *http.Request)
|
||||
// List documents
|
||||
// (GET /documents)
|
||||
GetDocuments(w http.ResponseWriter, r *http.Request, params GetDocumentsParams)
|
||||
@@ -961,6 +967,20 @@ func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request)
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Register operation middleware
|
||||
func (siw *ServerInterfaceWrapper) Register(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.Register(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetDocuments operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1606,6 +1626,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
m.HandleFunc("POST "+options.BaseURL+"/auth/login", wrapper.Login)
|
||||
m.HandleFunc("POST "+options.BaseURL+"/auth/logout", wrapper.Logout)
|
||||
m.HandleFunc("GET "+options.BaseURL+"/auth/me", wrapper.GetMe)
|
||||
m.HandleFunc("POST "+options.BaseURL+"/auth/register", wrapper.Register)
|
||||
m.HandleFunc("GET "+options.BaseURL+"/documents", wrapper.GetDocuments)
|
||||
m.HandleFunc("POST "+options.BaseURL+"/documents", wrapper.CreateDocument)
|
||||
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}", wrapper.GetDocument)
|
||||
@@ -2072,6 +2093,50 @@ func (response GetMe401JSONResponse) VisitGetMeResponse(w http.ResponseWriter) e
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type RegisterRequestObject struct {
|
||||
Body *RegisterJSONRequestBody
|
||||
}
|
||||
|
||||
type RegisterResponseObject interface {
|
||||
VisitRegisterResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type Register201JSONResponse LoginResponse
|
||||
|
||||
func (response Register201JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(201)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type Register400JSONResponse ErrorResponse
|
||||
|
||||
func (response Register400JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(400)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type Register403JSONResponse ErrorResponse
|
||||
|
||||
func (response Register403JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(403)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type Register500JSONResponse ErrorResponse
|
||||
|
||||
func (response Register500JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(500)
|
||||
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
type GetDocumentsRequestObject struct {
|
||||
Params GetDocumentsParams
|
||||
}
|
||||
@@ -2864,6 +2929,9 @@ type StrictServerInterface interface {
|
||||
// Get current user info
|
||||
// (GET /auth/me)
|
||||
GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error)
|
||||
// User registration
|
||||
// (POST /auth/register)
|
||||
Register(ctx context.Context, request RegisterRequestObject) (RegisterResponseObject, error)
|
||||
// List documents
|
||||
// (GET /documents)
|
||||
GetDocuments(ctx context.Context, request GetDocumentsRequestObject) (GetDocumentsResponseObject, error)
|
||||
@@ -3279,6 +3347,37 @@ func (sh *strictHandler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Register operation middleware
|
||||
func (sh *strictHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var request RegisterRequestObject
|
||||
|
||||
var body RegisterJSONRequestBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
|
||||
return
|
||||
}
|
||||
request.Body = &body
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.Register(ctx, request.(RegisterRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "Register")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(RegisterResponseObject); ok {
|
||||
if err := validResponse.VisitRegisterResponse(w); err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
}
|
||||
} else if response != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||
}
|
||||
}
|
||||
|
||||
// GetDocuments operation middleware
|
||||
func (sh *strictHandler) GetDocuments(w http.ResponseWriter, r *http.Request, params GetDocumentsParams) {
|
||||
var request GetDocumentsRequestObject
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Get request and response from context (set by middleware)
|
||||
r := s.getRequestFromContext(ctx)
|
||||
w := s.getResponseWriterFromContext(ctx)
|
||||
|
||||
if r == nil || w == nil {
|
||||
return Login500JSONResponse{Code: 500, Message: "Internal context error"}, nil
|
||||
}
|
||||
|
||||
// Create session with cookie options for Vite proxy compatibility
|
||||
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
|
||||
if s.cfg.CookieEncKey != "" {
|
||||
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
|
||||
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
|
||||
}
|
||||
}
|
||||
|
||||
session, err := store.Get(r, "token")
|
||||
if err != nil {
|
||||
return Login401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||
}
|
||||
|
||||
// Configure cookie options to work with Vite proxy
|
||||
// For localhost development, we need SameSite to allow cookies across ports
|
||||
session.Options.SameSite = http.SameSiteLaxMode
|
||||
session.Options.HttpOnly = true
|
||||
if !s.cfg.CookieSecure {
|
||||
session.Options.Secure = false // Allow HTTP for localhost development
|
||||
} else {
|
||||
session.Options.Secure = true
|
||||
}
|
||||
|
||||
session.Values["authorizedUser"] = user.ID
|
||||
session.Values["isAdmin"] = user.Admin
|
||||
session.Values["expiresAt"] = time.Now().Unix() + (60 * 60 * 24 * 7)
|
||||
session.Values["authHash"] = *user.AuthHash
|
||||
|
||||
if err := session.Save(r, w); err != nil {
|
||||
return Login500JSONResponse{Code: 500, Message: "Failed to create session"}, nil
|
||||
if err := s.saveUserSession(ctx, user.ID, user.Admin, *user.AuthHash); err != nil {
|
||||
return Login500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||
}
|
||||
|
||||
return Login200JSONResponse{
|
||||
@@ -82,6 +46,46 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
|
||||
}, nil
|
||||
}
|
||||
|
||||
// POST /auth/register
|
||||
func (s *Server) Register(ctx context.Context, request RegisterRequestObject) (RegisterResponseObject, error) {
|
||||
if !s.cfg.RegistrationEnabled {
|
||||
return Register403JSONResponse{Code: 403, Message: "Registration is disabled"}, nil
|
||||
}
|
||||
|
||||
if request.Body == nil {
|
||||
return Register400JSONResponse{Code: 400, Message: "Invalid request body"}, nil
|
||||
}
|
||||
|
||||
req := *request.Body
|
||||
if req.Username == "" || req.Password == "" {
|
||||
return Register400JSONResponse{Code: 400, Message: "Invalid user or password"}, nil
|
||||
}
|
||||
|
||||
currentUsers, err := s.db.Queries.GetUsers(ctx)
|
||||
if err != nil {
|
||||
return Register500JSONResponse{Code: 500, Message: "Failed to create user"}, nil
|
||||
}
|
||||
|
||||
isAdmin := len(currentUsers) == 0
|
||||
if err := s.createUser(ctx, req.Username, &req.Password, &isAdmin); err != nil {
|
||||
return Register400JSONResponse{Code: 400, Message: err.Error()}, nil
|
||||
}
|
||||
|
||||
user, err := s.db.Queries.GetUser(ctx, req.Username)
|
||||
if err != nil {
|
||||
return Register500JSONResponse{Code: 500, Message: "Failed to load created user"}, nil
|
||||
}
|
||||
|
||||
if err := s.saveUserSession(ctx, user.ID, user.Admin, *user.AuthHash); err != nil {
|
||||
return Register500JSONResponse{Code: 500, Message: err.Error()}, nil
|
||||
}
|
||||
|
||||
return Register201JSONResponse{
|
||||
Username: user.ID,
|
||||
IsAdmin: user.Admin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// POST /auth/logout
|
||||
func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) {
|
||||
_, ok := s.getSessionFromContext(ctx)
|
||||
@@ -96,28 +100,11 @@ func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (Logou
|
||||
return Logout401JSONResponse{Code: 401, Message: "Internal context error"}, nil
|
||||
}
|
||||
|
||||
// Create session store
|
||||
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
|
||||
if s.cfg.CookieEncKey != "" {
|
||||
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
|
||||
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
|
||||
}
|
||||
}
|
||||
|
||||
session, err := store.Get(r, "token")
|
||||
session, err := s.getCookieSession(r)
|
||||
if err != nil {
|
||||
return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
|
||||
}
|
||||
|
||||
// Configure cookie options (same as login)
|
||||
session.Options.SameSite = http.SameSiteLaxMode
|
||||
session.Options.HttpOnly = true
|
||||
if !s.cfg.CookieSecure {
|
||||
session.Options.Secure = false
|
||||
} else {
|
||||
session.Options.Secure = true
|
||||
}
|
||||
|
||||
session.Values = make(map[any]any)
|
||||
|
||||
if err := session.Save(r, w); err != nil {
|
||||
@@ -140,6 +127,50 @@ func (s *Server) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeRe
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) saveUserSession(ctx context.Context, username string, isAdmin bool, authHash string) error {
|
||||
r := s.getRequestFromContext(ctx)
|
||||
w := s.getResponseWriterFromContext(ctx)
|
||||
if r == nil || w == nil {
|
||||
return fmt.Errorf("internal context error")
|
||||
}
|
||||
|
||||
session, err := s.getCookieSession(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
session.Values["authorizedUser"] = username
|
||||
session.Values["isAdmin"] = isAdmin
|
||||
session.Values["expiresAt"] = time.Now().Unix() + (60 * 60 * 24 * 7)
|
||||
session.Values["authHash"] = authHash
|
||||
|
||||
if err := session.Save(r, w); err != nil {
|
||||
return fmt.Errorf("failed to create session")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getCookieSession(r *http.Request) (*sessions.Session, error) {
|
||||
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
|
||||
if s.cfg.CookieEncKey != "" {
|
||||
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
|
||||
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
|
||||
}
|
||||
}
|
||||
|
||||
session, err := store.Get(r, "token")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
|
||||
session.Options.SameSite = http.SameSiteLaxMode
|
||||
session.Options.HttpOnly = true
|
||||
session.Options.Secure = s.cfg.CookieSecure
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// getSessionFromContext extracts authData from context
|
||||
func (s *Server) getSessionFromContext(ctx context.Context) (authData, bool) {
|
||||
auth, ok := ctx.Value("auth").(authData)
|
||||
|
||||
@@ -25,16 +25,16 @@ type AuthTestSuite struct {
|
||||
|
||||
func (suite *AuthTestSuite) setupConfig() *config.Config {
|
||||
return &config.Config{
|
||||
ListenPort: "8080",
|
||||
DBType: "memory",
|
||||
DBName: "test",
|
||||
ConfigPath: "/tmp",
|
||||
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
||||
CookieEncKey: "0123456789abcdef",
|
||||
CookieSecure: false,
|
||||
CookieHTTPOnly: true,
|
||||
Version: "test",
|
||||
DemoMode: false,
|
||||
ListenPort: "8080",
|
||||
DBType: "memory",
|
||||
DBName: "test",
|
||||
ConfigPath: "/tmp",
|
||||
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
|
||||
CookieEncKey: "0123456789abcdef",
|
||||
CookieSecure: false,
|
||||
CookieHTTPOnly: true,
|
||||
Version: "test",
|
||||
DemoMode: false,
|
||||
RegistrationEnabled: true,
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,51 @@ func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() {
|
||||
suite.Equal(http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
func (suite *AuthTestSuite) TestAPIRegister() {
|
||||
reqBody := LoginRequest{
|
||||
Username: "newuser",
|
||||
Password: "newpass",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
suite.srv.ServeHTTP(w, req)
|
||||
|
||||
suite.Equal(http.StatusCreated, w.Code)
|
||||
|
||||
var resp LoginResponse
|
||||
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
suite.Equal("newuser", resp.Username)
|
||||
suite.True(resp.IsAdmin, "first registered user should mirror legacy admin bootstrap behavior")
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
suite.Require().NotEmpty(cookies, "register should set a session cookie")
|
||||
|
||||
user, err := suite.db.Queries.GetUser(suite.T().Context(), "newuser")
|
||||
suite.Require().NoError(err)
|
||||
suite.True(user.Admin)
|
||||
}
|
||||
|
||||
func (suite *AuthTestSuite) TestAPIRegisterDisabled() {
|
||||
suite.cfg.RegistrationEnabled = false
|
||||
suite.srv = NewServer(suite.db, suite.cfg, nil)
|
||||
|
||||
reqBody := LoginRequest{
|
||||
Username: "newuser",
|
||||
Password: "newpass",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
suite.srv.ServeHTTP(w, req)
|
||||
|
||||
suite.Equal(http.StatusForbidden, w.Code)
|
||||
}
|
||||
|
||||
func (suite *AuthTestSuite) TestAPILogout() {
|
||||
suite.createTestUser("testuser", "testpass")
|
||||
cookie := suite.login("testuser", "testpass")
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"reichard.io/antholume/database"
|
||||
"reichard.io/antholume/metadata"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GET /documents
|
||||
@@ -81,7 +81,7 @@ func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestOb
|
||||
LastRead: parseInterfaceTime(row.LastRead),
|
||||
CreatedAt: time.Now(), // Will be overwritten if we had a proper created_at from DB
|
||||
UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB
|
||||
Deleted: false, // Default, should be overridden if available
|
||||
Deleted: false, // Default, should be overridden if available
|
||||
}
|
||||
if row.Words != nil {
|
||||
wordCounts = append(wordCounts, WordCount{
|
||||
@@ -217,10 +217,10 @@ func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestOb
|
||||
Isbn13: request.Body.Isbn13,
|
||||
Coverfile: coverFileName,
|
||||
// Preserve existing values for non-editable fields
|
||||
Md5: currentDoc.Md5,
|
||||
Basepath: currentDoc.Basepath,
|
||||
Filepath: currentDoc.Filepath,
|
||||
Words: currentDoc.Words,
|
||||
Md5: currentDoc.Md5,
|
||||
Basepath: currentDoc.Basepath,
|
||||
Filepath: currentDoc.Filepath,
|
||||
Words: currentDoc.Words,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("UpsertDocument DB Error:", err)
|
||||
@@ -306,7 +306,7 @@ func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
|
||||
}
|
||||
|
||||
// parseInterfaceTime converts an interface{} to time.Time for SQLC queries
|
||||
func parseInterfaceTime(t interface{}) *time.Time {
|
||||
func parseInterfaceTime(t any) *time.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -771,17 +771,17 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
|
||||
// Document already exists
|
||||
existingDoc, _ := s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5)
|
||||
apiDoc := Document{
|
||||
Id: existingDoc.ID,
|
||||
Title: *existingDoc.Title,
|
||||
Author: *existingDoc.Author,
|
||||
Id: existingDoc.ID,
|
||||
Title: *existingDoc.Title,
|
||||
Author: *existingDoc.Author,
|
||||
Description: existingDoc.Description,
|
||||
Isbn10: existingDoc.Isbn10,
|
||||
Isbn13: existingDoc.Isbn13,
|
||||
Words: existingDoc.Words,
|
||||
Isbn10: existingDoc.Isbn10,
|
||||
Isbn13: existingDoc.Isbn13,
|
||||
Words: existingDoc.Words,
|
||||
Filepath: existingDoc.Filepath,
|
||||
CreatedAt: parseTime(existingDoc.CreatedAt),
|
||||
CreatedAt: parseTime(existingDoc.CreatedAt),
|
||||
UpdatedAt: parseTime(existingDoc.UpdatedAt),
|
||||
Deleted: existingDoc.Deleted,
|
||||
Deleted: existingDoc.Deleted,
|
||||
}
|
||||
response := DocumentResponse{
|
||||
Document: apiDoc,
|
||||
@@ -818,17 +818,17 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
|
||||
}
|
||||
|
||||
apiDoc := Document{
|
||||
Id: doc.ID,
|
||||
Title: *doc.Title,
|
||||
Author: *doc.Author,
|
||||
Id: doc.ID,
|
||||
Title: *doc.Title,
|
||||
Author: *doc.Author,
|
||||
Description: doc.Description,
|
||||
Isbn10: doc.Isbn10,
|
||||
Isbn13: doc.Isbn13,
|
||||
Words: doc.Words,
|
||||
Isbn10: doc.Isbn10,
|
||||
Isbn13: doc.Isbn13,
|
||||
Words: doc.Words,
|
||||
Filepath: doc.Filepath,
|
||||
CreatedAt: parseTime(doc.CreatedAt),
|
||||
CreatedAt: parseTime(doc.CreatedAt),
|
||||
UpdatedAt: parseTime(doc.UpdatedAt),
|
||||
Deleted: doc.Deleted,
|
||||
Deleted: doc.Deleted,
|
||||
}
|
||||
|
||||
response := DocumentResponse{
|
||||
|
||||
@@ -1182,6 +1182,44 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/auth/register:
|
||||
post:
|
||||
summary: User registration
|
||||
operationId: register
|
||||
tags:
|
||||
- Auth
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginRequest'
|
||||
responses:
|
||||
201:
|
||||
description: Successful registration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginResponse'
|
||||
400:
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
403:
|
||||
description: Registration disabled
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
500:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/auth/logout:
|
||||
post:
|
||||
summary: User logout
|
||||
|
||||
@@ -46,8 +46,8 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
|
||||
ctx = context.WithValue(ctx, "request", r)
|
||||
ctx = context.WithValue(ctx, "response", w)
|
||||
|
||||
// Skip auth for login and info endpoints - cover and file require auth via cookies
|
||||
if operationID == "Login" || operationID == "GetInfo" {
|
||||
// Skip auth for public auth and info endpoints - cover and file require auth via cookies
|
||||
if operationID == "Login" || operationID == "Register" || operationID == "GetInfo" {
|
||||
return handler(ctx, w, r, request)
|
||||
}
|
||||
|
||||
@@ -92,10 +92,8 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
|
||||
// GetInfo returns server information
|
||||
func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) {
|
||||
return GetInfo200JSONResponse{
|
||||
Version: s.cfg.Version,
|
||||
SearchEnabled: s.cfg.SearchEnabled,
|
||||
RegistrationEnabled: s.cfg.RegistrationEnabled,
|
||||
Version: s.cfg.Version,
|
||||
SearchEnabled: s.cfg.SearchEnabled,
|
||||
RegistrationEnabled: s.cfg.RegistrationEnabled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import ActivityPage from './pages/ActivityPage';
|
||||
import SearchPage from './pages/SearchPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
import AdminImportPage from './pages/AdminImportPage';
|
||||
import AdminImportResultsPage from './pages/AdminImportResultsPage';
|
||||
@@ -118,6 +119,7 @@ export function Routes() {
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
</ReactRoutes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLogin, useLogout, useGetMe } from '../generated/anthoLumeAPIV1';
|
||||
import {
|
||||
getGetMeQueryKey,
|
||||
useLogin,
|
||||
useLogout,
|
||||
useGetMe,
|
||||
useRegister,
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
@@ -10,6 +17,7 @@ interface AuthState {
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (_username: string, _password: string) => Promise<void>;
|
||||
register: (_username: string, _password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@@ -19,27 +27,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true, // Start with checking state to prevent redirects during initial load
|
||||
isCheckingAuth: true,
|
||||
});
|
||||
|
||||
const loginMutation = useLogin();
|
||||
const registerMutation = useRegister();
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
// Always call /me to check authentication status
|
||||
const { data: meData, error: meError, isLoading: meLoading } = useGetMe();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Update auth state based on /me endpoint response
|
||||
useEffect(() => {
|
||||
setAuthState(prev => {
|
||||
if (meLoading) {
|
||||
// Still checking authentication
|
||||
console.log('[AuthContext] Checking authentication status...');
|
||||
return { ...prev, isCheckingAuth: true };
|
||||
} else if (meData?.data && meData.status === 200) {
|
||||
// User is authenticated - check that response has valid data
|
||||
console.log('[AuthContext] User authenticated:', meData.data);
|
||||
const userData = 'username' in meData.data ? meData.data : null;
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
@@ -47,16 +51,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
} else if (meError || (meData && meData.status === 401)) {
|
||||
// User is not authenticated or error occurred
|
||||
console.log('[AuthContext] User not authenticated:', meError?.message || String(meError));
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
console.log('[AuthContext] Unexpected state - checking...');
|
||||
return { ...prev, isCheckingAuth: false }; // Assume not authenticated if we can't determine
|
||||
|
||||
return { ...prev, isCheckingAuth: false };
|
||||
});
|
||||
}, [meData, meError, meLoading]);
|
||||
|
||||
@@ -70,41 +72,92 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
});
|
||||
|
||||
// The backend uses session-based authentication, so no token to store
|
||||
// The session cookie is automatically set by the browser
|
||||
if (response.status !== 200 || !('username' in response.data)) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user:
|
||||
'username' in response.data
|
||||
? (response.data as { username: string; is_admin: boolean })
|
||||
: null,
|
||||
user: response.data as { username: string; is_admin: boolean },
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
console.error('[AuthContext] Login failed:', _error);
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
},
|
||||
[loginMutation, navigate]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
},
|
||||
[loginMutation, navigate, queryClient]
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await registerMutation.mutateAsync({
|
||||
data: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status !== 201 || !('username' in response.data)) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: response.data as { username: string; is_admin: boolean },
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
},
|
||||
[navigate, queryClient, registerMutation]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: async () => {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
await queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/login');
|
||||
},
|
||||
});
|
||||
}, [logoutMutation, navigate]);
|
||||
}, [logoutMutation, navigate, queryClient]);
|
||||
|
||||
return (
|
||||
<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 { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
|
||||
const { data } = useGetMe(isAuthenticated ? {} : undefined);
|
||||
const userData = data?.data || user;
|
||||
const fetchedUser =
|
||||
data?.status === 200 && data.data && 'username' in data.data ? data.data : null;
|
||||
const userData = user ?? fetchedUser;
|
||||
const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -34,15 +36,26 @@ export default function Layout() {
|
||||
|
||||
// Get current page title
|
||||
const navItems = [
|
||||
{ path: '/', title: 'Home' },
|
||||
{ path: '/admin/import-results', title: 'Admin - Import' },
|
||||
{ path: '/admin/import', title: 'Admin - Import' },
|
||||
{ path: '/admin/users', title: 'Admin - Users' },
|
||||
{ path: '/admin/logs', title: 'Admin - Logs' },
|
||||
{ path: '/admin', title: 'Admin - General' },
|
||||
{ path: '/documents', title: 'Documents' },
|
||||
{ path: '/progress', title: 'Progress' },
|
||||
{ path: '/activity', title: 'Activity' },
|
||||
{ path: '/search', title: 'Search' },
|
||||
{ path: '/settings', title: 'Settings' },
|
||||
{ path: '/', title: 'Home' },
|
||||
];
|
||||
const currentPageTitle =
|
||||
navItems.find(item => location.pathname === item.path)?.title || 'Documents';
|
||||
navItems.find(item =>
|
||||
item.path === '/' ? location.pathname === item.path : location.pathname.startsWith(item.path)
|
||||
)?.title || 'Home';
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `AnthoLume - ${currentPageTitle}`;
|
||||
}, [currentPageTitle]);
|
||||
|
||||
// Show loading while checking authentication status
|
||||
if (isCheckingAuth) {
|
||||
@@ -62,7 +75,9 @@ export default function Layout() {
|
||||
<HamburgerMenu />
|
||||
|
||||
{/* 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 */}
|
||||
<div
|
||||
@@ -78,7 +93,7 @@ export default function Layout() {
|
||||
|
||||
{isUserDropdownOpen && (
|
||||
<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
|
||||
className="py-1"
|
||||
role="menu"
|
||||
|
||||
@@ -2,14 +2,14 @@ import React from 'react';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
export interface Column<T> {
|
||||
export interface Column<T extends Record<string, unknown>> {
|
||||
key: keyof T;
|
||||
header: string;
|
||||
render?: (value: any, _row: T, _index: number) => React.ReactNode;
|
||||
render?: (value: T[keyof T], _row: T, _index: number) => React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface TableProps<T> {
|
||||
export interface TableProps<T extends Record<string, unknown>> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
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,
|
||||
data,
|
||||
loading = false,
|
||||
|
||||
@@ -1690,6 +1690,112 @@ export const useLogin = <TError = ErrorResponse,
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,11 @@ interface FolderOpenIconProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FolderOpenIcon({ size = 24, className = '', disabled = false }: FolderOpenIconProps) {
|
||||
export function FolderOpenIcon({
|
||||
size = 24,
|
||||
className = '',
|
||||
disabled = false,
|
||||
}: FolderOpenIconProps) {
|
||||
return (
|
||||
<BaseIcon size={size} className={className} disabled={disabled}>
|
||||
<path
|
||||
|
||||
@@ -3,6 +3,10 @@ interface LoadingIconProps {
|
||||
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) {
|
||||
return (
|
||||
<svg
|
||||
@@ -15,15 +19,6 @@ export function LoadingIcon({ size = 24, className = '' }: LoadingIconProps) {
|
||||
>
|
||||
<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 {
|
||||
0% {
|
||||
transform: translate(12px, 12px) scale(0);
|
||||
@@ -37,19 +32,19 @@ export function LoadingIcon({ size = 24, className = '' }: LoadingIconProps) {
|
||||
`}
|
||||
</style>
|
||||
<path
|
||||
className="spinner_l9ve"
|
||||
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"
|
||||
d={spinnerPath}
|
||||
transform="translate(12, 12) scale(0)"
|
||||
style={{ animation: spinnerAnimation }}
|
||||
/>
|
||||
<path
|
||||
className="spinner_l9ve spinner_cMYp"
|
||||
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"
|
||||
d={spinnerPath}
|
||||
transform="translate(12, 12) scale(0)"
|
||||
style={{ animation: spinnerAnimation, animationDelay: '0.4s' }}
|
||||
/>
|
||||
<path
|
||||
className="spinner_l9ve spinner_gHR3"
|
||||
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"
|
||||
d={spinnerPath}
|
||||
transform="translate(12, 12) scale(0)"
|
||||
style={{ animation: spinnerAnimation, animationDelay: '0.8s' }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetActivity } from '../generated/anthoLumeAPIV1';
|
||||
import type { Activity } from '../generated/model';
|
||||
import { Table } from '../components/Table';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
|
||||
export default function ActivityPage() {
|
||||
const { data, isLoading } = useGetActivity({ offset: 0, limit: 100 });
|
||||
const activities = data?.data?.activities;
|
||||
const activities = data?.status === 200 ? data.data.activities : [];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'document_id' as const,
|
||||
header: 'Document',
|
||||
render: (_: any, row: any) => (
|
||||
render: (_value: Activity['document_id'], row: Activity) => (
|
||||
<Link
|
||||
to={`/documents/${row.document_id}`}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
@@ -23,19 +24,19 @@ export default function ActivityPage() {
|
||||
{
|
||||
key: 'start_time' as const,
|
||||
header: 'Time',
|
||||
render: (value: any) => value || 'N/A',
|
||||
render: (value: Activity['start_time']) => value || 'N/A',
|
||||
},
|
||||
{
|
||||
key: 'duration' as const,
|
||||
header: 'Duration',
|
||||
render: (value: any) => {
|
||||
render: (value: Activity['duration']) => {
|
||||
return formatDuration(value || 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'end_percentage' as const,
|
||||
header: 'Percent',
|
||||
render: (value: any) => (value != null ? `${value}%` : '0%'),
|
||||
render: (value: Activity['end_percentage']) => (value != null ? `${value}%` : '0%'),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useGetImportDirectory, usePostImport } from '../generated/anthoLumeAPIV1';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { Button } from '../components/Button';
|
||||
import { FolderOpenIcon } from '../icons';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
@@ -50,7 +51,7 @@ export default function AdminImportPage() {
|
||||
}, 1500);
|
||||
},
|
||||
onError: error => {
|
||||
showError('Import failed: ' + (error as any).message);
|
||||
showError('Import failed: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, FormEvent } from 'react';
|
||||
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
interface BackupTypes {
|
||||
covers: boolean;
|
||||
@@ -43,10 +44,10 @@ export default function AdminPage() {
|
||||
|
||||
// Stream the response directly to disk using File System Access API
|
||||
// This avoids loading multi-GB files into browser memory
|
||||
if (typeof (window as any).showSaveFilePicker === 'function') {
|
||||
if ('showSaveFilePicker' in window && typeof window.showSaveFilePicker === 'function') {
|
||||
try {
|
||||
// Modern browsers: Use File System Access API for direct disk writes
|
||||
const handle = await (window as any).showSaveFilePicker({
|
||||
const handle = await window.showSaveFilePicker({
|
||||
suggestedName: filename,
|
||||
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
|
||||
});
|
||||
@@ -78,7 +79,7 @@ export default function AdminPage() {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Backup failed: ' + (error as any).message);
|
||||
showError('Backup failed: ' + getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,7 +99,7 @@ export default function AdminPage() {
|
||||
showInfo('Restore completed successfully');
|
||||
},
|
||||
onError: error => {
|
||||
showError('Restore failed: ' + (error as any).message);
|
||||
showError('Restore failed: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -116,7 +117,7 @@ export default function AdminPage() {
|
||||
showInfo('Metadata matching started');
|
||||
},
|
||||
onError: error => {
|
||||
showError('Metadata matching failed: ' + (error as any).message);
|
||||
showError('Metadata matching failed: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -134,7 +135,7 @@ export default function AdminPage() {
|
||||
showInfo('Cache tables started');
|
||||
},
|
||||
onError: error => {
|
||||
showError('Cache tables failed: ' + (error as any).message);
|
||||
showError('Cache tables failed: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, FormEvent } from 'react';
|
||||
import { useGetUsers, useUpdateUser } from '../generated/anthoLumeAPIV1';
|
||||
import { AddIcon, DeleteIcon } from '../icons';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { data: usersData, isLoading, refetch } = useGetUsers({});
|
||||
@@ -37,8 +38,8 @@ export default function AdminUsersPage() {
|
||||
setNewIsAdmin(false);
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
showError('Failed to create user: ' + error.message);
|
||||
onError: error => {
|
||||
showError('Failed to create user: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -57,8 +58,8 @@ export default function AdminUsersPage() {
|
||||
showInfo('User deleted successfully');
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
showError('Failed to delete user: ' + error.message);
|
||||
onError: error => {
|
||||
showError('Failed to delete user: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -80,8 +81,8 @@ export default function AdminUsersPage() {
|
||||
showInfo('Password updated successfully');
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
showError('Failed to update password: ' + error.message);
|
||||
onError: error => {
|
||||
showError('Failed to update password: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -102,8 +103,8 @@ export default function AdminUsersPage() {
|
||||
showInfo(`User permissions updated to ${role}`);
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
showError('Failed to update admin status: ' + error.message);
|
||||
onError: error => {
|
||||
showError('Failed to update admin status: ' + getErrorMessage(error));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { useState, FormEvent, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||
import type { DocumentsResponse } from '../generated/model/documentsResponse';
|
||||
import type { Document, DocumentsResponse } from '../generated/model';
|
||||
import { ActivityIcon, DownloadIcon, SearchIcon, UploadIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
interface DocumentCardProps {
|
||||
doc: {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
created_at: string;
|
||||
deleted: boolean;
|
||||
words?: number;
|
||||
filepath?: string;
|
||||
percentage?: number;
|
||||
total_time_seconds?: number;
|
||||
};
|
||||
doc: Document;
|
||||
}
|
||||
|
||||
function DocumentCard({ doc }: DocumentCardProps) {
|
||||
@@ -125,8 +116,8 @@ export default function DocumentsPage() {
|
||||
showInfo('Document uploaded successfully!');
|
||||
setUploadMode(false);
|
||||
refetch();
|
||||
} catch (error: any) {
|
||||
showError('Failed to upload document: ' + error.message);
|
||||
} catch (error) {
|
||||
showError('Failed to upload document: ' + getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,7 +161,7 @@ export default function DocumentsPage() {
|
||||
{isLoading ? (
|
||||
<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>
|
||||
|
||||
@@ -220,7 +211,9 @@ export default function DocumentsPage() {
|
||||
type="submit"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
handleFileChange({ target: { files: fileInputRef.current?.files } } as any);
|
||||
handleFileChange({
|
||||
target: { files: fileInputRef.current?.files },
|
||||
} as React.ChangeEvent<HTMLInputElement>);
|
||||
}}
|
||||
>
|
||||
Upload File
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetHome } from '../generated/anthoLumeAPIV1';
|
||||
import type { LeaderboardData } from '../generated/model';
|
||||
import type {
|
||||
HomeResponse,
|
||||
LeaderboardData,
|
||||
LeaderboardEntry,
|
||||
UserStreak,
|
||||
} from '../generated/model';
|
||||
import ReadingHistoryGraph from '../components/ReadingHistoryGraph';
|
||||
import { formatNumber, formatDuration } from '../utils/formatters';
|
||||
|
||||
@@ -127,7 +132,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
<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
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => handlePeriodChange('all')}
|
||||
@@ -172,7 +177,7 @@ function LeaderboardCard({ name, data }: LeaderboardCardProps) {
|
||||
</div>
|
||||
|
||||
<div className="dark:text-white">
|
||||
{currentData?.slice(0, 3).map((item: any, index: number) => (
|
||||
{currentData?.slice(0, 3).map((item: LeaderboardEntry, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
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() {
|
||||
const { data: homeData, isLoading: homeLoading } = useGetHome();
|
||||
|
||||
const dbInfo = homeData?.data?.database_info;
|
||||
const streaks = homeData?.data?.streaks?.streaks;
|
||||
const graphData = homeData?.data?.graph_data?.graph_data;
|
||||
const userStats = homeData?.data?.user_statistics;
|
||||
const homeResponse = homeData?.status === 200 ? (homeData.data as HomeResponse) : null;
|
||||
const dbInfo = homeResponse?.database_info;
|
||||
const streaks = homeResponse?.streaks?.streaks;
|
||||
const graphData = homeResponse?.graph_data?.graph_data;
|
||||
const userStats = homeResponse?.user_statistics;
|
||||
|
||||
if (homeLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
@@ -223,7 +229,7 @@ export default function HomePage() {
|
||||
|
||||
{/* Streak Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{streaks?.map((streak: any, index) => (
|
||||
{streaks?.map((streak: UserStreak, index: number) => (
|
||||
<StreakCard
|
||||
key={index}
|
||||
window={streak.window as 'DAY' | 'WEEK'}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -12,8 +13,17 @@ export default function LoginPage() {
|
||||
const { login, isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
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(() => {
|
||||
if (!isCheckingAuth && isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
@@ -76,7 +86,15 @@ export default function LoginPage() {
|
||||
</Button>
|
||||
</form>
|
||||
<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">
|
||||
Offline / Local Mode
|
||||
</a>
|
||||
@@ -84,7 +102,7 @@ export default function LoginPage() {
|
||||
</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">
|
||||
<span className="text-gray-500">AnthoLume</span>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetProgressList } from '../generated/anthoLumeAPIV1';
|
||||
import type { Progress } from '../generated/model';
|
||||
import { Table } from '../components/Table';
|
||||
|
||||
export default function ProgressPage() {
|
||||
const { data, isLoading } = useGetProgressList({ page: 1, limit: 15 });
|
||||
const progress = data?.data?.progress;
|
||||
const progress = data?.status === 200 ? (data.data.progress ?? []) : [];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'document_id' as const,
|
||||
header: 'Document',
|
||||
render: (_: any, row: any) => (
|
||||
render: (_value: Progress['document_id'], row: Progress) => (
|
||||
<Link
|
||||
to={`/documents/${row.document_id}`}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
@@ -22,17 +23,18 @@ export default function ProgressPage() {
|
||||
{
|
||||
key: 'device_name' as const,
|
||||
header: 'Device Name',
|
||||
render: (value: any) => value || 'Unknown',
|
||||
render: (value: Progress['device_name']) => value || 'Unknown',
|
||||
},
|
||||
{
|
||||
key: 'percentage' as const,
|
||||
header: 'Percentage',
|
||||
render: (value: any) => (value ? `${Math.round(value)}%` : '0%'),
|
||||
render: (value: Progress['percentage']) => (value ? `${Math.round(value)}%` : '0%'),
|
||||
},
|
||||
{
|
||||
key: 'created_at' as const,
|
||||
header: 'Created At',
|
||||
render: (value: any) => (value ? new Date(value).toLocaleDateString() : 'N/A'),
|
||||
render: (value: Progress['created_at']) =>
|
||||
value ? new Date(value).toLocaleDateString() : 'N/A',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
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 { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||
import type { SearchItem } from '../generated/model';
|
||||
import { SearchIcon, DownloadIcon, BookIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
|
||||
@@ -9,7 +10,7 @@ export default function SearchPage() {
|
||||
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
|
||||
|
||||
const { data, isLoading } = useGetSearch({ query, source });
|
||||
const results = data?.data?.results;
|
||||
const results = data?.status === 200 ? data.data.results : [];
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -97,7 +98,7 @@ export default function SearchPage() {
|
||||
)}
|
||||
{!isLoading &&
|
||||
results &&
|
||||
results.map((item: any) => (
|
||||
results.map((item: SearchItem) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border-b border-gray-200 p-3 text-gray-500 dark:text-gray-500">
|
||||
<button className="hover:text-purple-600" title="Download">
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { useGetSettings, useUpdateSettings } from '../generated/anthoLumeAPIV1';
|
||||
import type { Device, SettingsResponse } from '../generated/model';
|
||||
import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data, isLoading } = useGetSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const settingsData = data;
|
||||
const settingsData = data?.status === 200 ? (data.data as SettingsResponse) : null;
|
||||
const { showInfo, showError } = useToasts();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -15,8 +17,8 @@ export default function SettingsPage() {
|
||||
const [timezone, setTimezone] = useState('UTC');
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsData?.data.timezone && settingsData.data.timezone.trim() !== '') {
|
||||
setTimezone(settingsData.data.timezone);
|
||||
if (settingsData?.timezone && settingsData.timezone.trim() !== '') {
|
||||
setTimezone(settingsData.timezone);
|
||||
}
|
||||
}, [settingsData]);
|
||||
|
||||
@@ -38,11 +40,8 @@ export default function SettingsPage() {
|
||||
showInfo('Password updated successfully');
|
||||
setPassword('');
|
||||
setNewPassword('');
|
||||
} catch (error: any) {
|
||||
showError(
|
||||
'Failed to update password: ' +
|
||||
(error.response?.data?.message || error.message || 'Unknown error')
|
||||
);
|
||||
} catch (error) {
|
||||
showError('Failed to update password: ' + getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -56,11 +55,8 @@ export default function SettingsPage() {
|
||||
},
|
||||
});
|
||||
showInfo('Timezone updated successfully');
|
||||
} catch (error: any) {
|
||||
showError(
|
||||
'Failed to update timezone: ' +
|
||||
(error.response?.data?.message || error.message || 'Unknown error')
|
||||
);
|
||||
} catch (error) {
|
||||
showError('Failed to update timezone: ' + getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -109,7 +105,7 @@ export default function SettingsPage() {
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -205,14 +201,14 @@ export default function SettingsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-black dark:text-white">
|
||||
{!settingsData?.data.devices || settingsData.data.devices.length === 0 ? (
|
||||
{!settingsData?.devices || settingsData.devices.length === 0 ? (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={3}>
|
||||
No Results
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
settingsData.data.devices.map((device: any) => (
|
||||
settingsData.devices.map((device: Device) => (
|
||||
<tr key={device.id}>
|
||||
<td className="p-3 pl-0">
|
||||
<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