Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cf71ab4a0 | ||
|
|
cf94697d07 | ||
|
|
94f11be77c | ||
|
|
6e642da57a | ||
|
|
6774c401bf | ||
|
|
1f1fd90890 |
@@ -26,3 +26,9 @@ CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_REQUESTS=50
|
||||
RATE_LIMIT_WINDOW=1s
|
||||
|
||||
# Redis Sessions
|
||||
REDIS_ADDR=localhost:6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
SESSION_TTL_HOURS=168
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||
"github.com/noteapp/backend/internal/interfaces/handlers"
|
||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -47,6 +49,31 @@ func main() {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
redisAddr := os.Getenv("REDIS_ADDR")
|
||||
if redisAddr == "" {
|
||||
redisAddr = "localhost:6379"
|
||||
}
|
||||
|
||||
redisUser := os.Getenv("REDIS_USER")
|
||||
redisPassword := os.Getenv("REDIS_PASSWORD")
|
||||
redisDB := 0
|
||||
if redisDBText := os.Getenv("REDIS_DB"); redisDBText != "" {
|
||||
parsedDB, err := strconv.Atoi(redisDBText)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid REDIS_DB value: %v", err)
|
||||
}
|
||||
redisDB = parsedDB
|
||||
}
|
||||
|
||||
sessionTTL := 7 * 24 * time.Hour
|
||||
if sessionTTLText := os.Getenv("SESSION_TTL_HOURS"); sessionTTLText != "" {
|
||||
hours, err := strconv.Atoi(sessionTTLText)
|
||||
if err != nil || hours <= 0 {
|
||||
log.Fatalf("invalid SESSION_TTL_HOURS value: %q", sessionTTLText)
|
||||
}
|
||||
sessionTTL = time.Duration(hours) * time.Hour
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -57,6 +84,20 @@ func main() {
|
||||
}
|
||||
defer db.Close(context.Background())
|
||||
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: redisAddr,
|
||||
Username: redisUser,
|
||||
Password: redisPassword,
|
||||
DB: redisDB,
|
||||
})
|
||||
|
||||
if err := redisClient.Ping(context.Background()).Err(); err != nil {
|
||||
log.Fatalf("failed to connect to redis: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = redisClient.Close()
|
||||
}()
|
||||
|
||||
// Initialize security components
|
||||
passwordHasher := security.NewPasswordHasher()
|
||||
encryptor, err := security.NewEncryptor(encryptionKey)
|
||||
@@ -66,6 +107,7 @@ func main() {
|
||||
|
||||
// Initialize JWT manager
|
||||
jwtManager := auth.NewJWTManager(jwtSecret, "noteapp", 1*time.Hour)
|
||||
sessionManager := auth.NewSessionManager(redisClient, sessionTTL)
|
||||
|
||||
// Initialize services
|
||||
permissionService := services.NewPermissionService(
|
||||
@@ -117,6 +159,8 @@ func main() {
|
||||
adminService := services.NewAdminService(
|
||||
db.UserRepo,
|
||||
db.GroupRepo,
|
||||
db.ProviderRepo,
|
||||
db.LinkRepo,
|
||||
db.SpaceRepo,
|
||||
db.MembershipRepo,
|
||||
db.NoteRepo,
|
||||
@@ -141,7 +185,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
authHandler := handlers.NewAuthHandler(authService)
|
||||
authHandler := handlers.NewAuthHandler(authService, sessionManager)
|
||||
spaceHandler := handlers.NewSpaceHandler(spaceService)
|
||||
noteHandler := handlers.NewNoteHandler(noteService)
|
||||
categoryHandler := handlers.NewCategoryHandler(categoryService)
|
||||
@@ -158,7 +202,7 @@ func main() {
|
||||
})
|
||||
|
||||
// Middleware
|
||||
authMiddleware := middleware.NewAuthMiddleware(jwtManager)
|
||||
authMiddleware := middleware.NewAuthMiddleware(jwtManager, sessionManager)
|
||||
router.Use(middleware.LoggingMiddleware)
|
||||
router.Use(middleware.CORSMiddleware)
|
||||
router.Use(middleware.SecurityHeaders)
|
||||
@@ -185,6 +229,7 @@ func main() {
|
||||
// Protected endpoints
|
||||
api := router.PathPrefix("/api/v1").Subrouter()
|
||||
api.Use(authMiddleware.Middleware)
|
||||
api.HandleFunc("/auth/me", authHandler.Me).Methods("GET")
|
||||
|
||||
// Space endpoints
|
||||
api.HandleFunc("/spaces", spaceHandler.GetUserSpaces).Methods("GET")
|
||||
@@ -255,10 +300,12 @@ func main() {
|
||||
})
|
||||
})
|
||||
admin.HandleFunc("/users", adminHandler.ListUsers).Methods("GET")
|
||||
admin.HandleFunc("/users/{userId}", adminHandler.DeleteUser).Methods("DELETE")
|
||||
admin.HandleFunc("/users/{userId}/groups", adminHandler.UpdateUserGroups).Methods("PUT")
|
||||
admin.HandleFunc("/groups", adminHandler.ListGroups).Methods("GET")
|
||||
admin.HandleFunc("/groups", adminHandler.CreateGroup).Methods("POST")
|
||||
admin.HandleFunc("/groups/{groupId}", adminHandler.UpdateGroup).Methods("PUT")
|
||||
admin.HandleFunc("/groups/{groupId}", adminHandler.DeleteGroup).Methods("DELETE")
|
||||
admin.HandleFunc("/spaces", adminHandler.ListAllSpaces).Methods("GET")
|
||||
admin.HandleFunc("/spaces/{spaceId}", adminHandler.UpdateSpace).Methods("PUT")
|
||||
admin.HandleFunc("/spaces/{spaceId}", adminHandler.DeleteSpace).Methods("DELETE")
|
||||
@@ -270,6 +317,8 @@ func main() {
|
||||
admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT")
|
||||
// manage identity providers — admin-only
|
||||
admin.HandleFunc("/auth/providers", authHandler.CreateProvider).Methods("POST")
|
||||
admin.HandleFunc("/auth/providers/{providerId}", authHandler.UpdateProvider).Methods("PUT")
|
||||
admin.HandleFunc("/auth/providers/{providerId}", adminHandler.DeleteProvider).Methods("DELETE")
|
||||
|
||||
// Serve static files (frontend) for all other routes
|
||||
// This must be after all API route handlers to allow API routes to take precedence
|
||||
|
||||
@@ -3,18 +3,20 @@ module github.com/noteapp/backend
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect
|
||||
@@ -22,13 +24,15 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/klauspost/compress v1.17.6 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
|
||||
@@ -22,8 +22,16 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 h1:MRNiP6nqa20aEl8fQ6PJpEq11b2d4
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2/go.mod h1:FrNA56srbsr3WShiaelyWYEo70x80mXnVZ17ZZfbeqg=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
@@ -34,6 +42,14 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
@@ -43,8 +59,12 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
|
||||
@@ -57,6 +57,21 @@ type CreateAuthProviderRequest struct {
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// UpdateAuthProviderRequest represents an OAuth/OIDC provider update request.
|
||||
// ClientSecret may be empty to keep the existing secret.
|
||||
type UpdateAuthProviderRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
AuthorizationURL string `json:"authorization_url"`
|
||||
TokenURL string `json:"token_url"`
|
||||
UserInfoURL string `json:"userinfo_url"`
|
||||
Scopes []string `json:"scopes"`
|
||||
IDTokenClaim string `json:"id_token_claim,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// FeatureFlagsDTO represents app-wide feature flags in API responses.
|
||||
type FeatureFlagsDTO struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
type AdminService struct {
|
||||
userRepo repositories.UserRepository
|
||||
groupRepo repositories.GroupRepository
|
||||
providerRepo repositories.AuthProviderRepository
|
||||
linkRepo repositories.UserProviderLinkRepository
|
||||
spaceRepo repositories.SpaceRepository
|
||||
membershipRepo repositories.MembershipRepository
|
||||
noteRepo repositories.NoteRepository
|
||||
@@ -30,6 +32,8 @@ type AdminService struct {
|
||||
func NewAdminService(
|
||||
userRepo repositories.UserRepository,
|
||||
groupRepo repositories.GroupRepository,
|
||||
providerRepo repositories.AuthProviderRepository,
|
||||
linkRepo repositories.UserProviderLinkRepository,
|
||||
spaceRepo repositories.SpaceRepository,
|
||||
membershipRepo repositories.MembershipRepository,
|
||||
noteRepo repositories.NoteRepository,
|
||||
@@ -41,6 +45,8 @@ func NewAdminService(
|
||||
return &AdminService{
|
||||
userRepo: userRepo,
|
||||
groupRepo: groupRepo,
|
||||
providerRepo: providerRepo,
|
||||
linkRepo: linkRepo,
|
||||
spaceRepo: spaceRepo,
|
||||
membershipRepo: membershipRepo,
|
||||
noteRepo: noteRepo,
|
||||
@@ -51,6 +57,114 @@ func NewAdminService(
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user and related memberships/provider links.
|
||||
func (s *AdminService) DeleteUser(ctx context.Context, currentUserID, targetUserID bson.ObjectID) error {
|
||||
if currentUserID == targetUserID {
|
||||
return errors.New("you cannot delete your own account")
|
||||
}
|
||||
|
||||
spaces, err := s.spaceRepo.GetAllSpaces(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, space := range spaces {
|
||||
if space.OwnerID == targetUserID {
|
||||
return errors.New("cannot delete user that owns spaces; transfer or delete spaces first")
|
||||
}
|
||||
}
|
||||
|
||||
memberships, err := s.membershipRepo.GetUserMemberships(ctx, targetUserID)
|
||||
if err == nil {
|
||||
for _, membership := range memberships {
|
||||
if err := s.membershipRepo.DeleteMembership(ctx, membership.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if s.linkRepo != nil {
|
||||
links, err := s.linkRepo.GetUserLinks(ctx, targetUserID)
|
||||
if err == nil {
|
||||
for _, link := range links {
|
||||
if err := s.linkRepo.DeleteLink(ctx, link.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s.userRepo.DeleteUser(ctx, targetUserID)
|
||||
}
|
||||
|
||||
// DeleteGroup deletes a non-system group and removes it from users.
|
||||
func (s *AdminService) DeleteGroup(ctx context.Context, groupID bson.ObjectID) error {
|
||||
group, err := s.groupRepo.GetGroupByID(ctx, groupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if group.IsSystem {
|
||||
return errors.New("system groups cannot be deleted")
|
||||
}
|
||||
|
||||
users, err := s.userRepo.ListAllUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range users {
|
||||
filtered := make([]bson.ObjectID, 0, len(user.GroupIDs))
|
||||
changed := false
|
||||
for _, assignedGroupID := range user.GroupIDs {
|
||||
if assignedGroupID == groupID {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, assignedGroupID)
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
user.GroupIDs = filtered
|
||||
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.groupRepo.DeleteGroup(ctx, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.refreshAllUserPermissions(ctx)
|
||||
}
|
||||
|
||||
// DeleteProvider deletes an auth provider and all user-provider links connected to it.
|
||||
func (s *AdminService) DeleteProvider(ctx context.Context, providerID bson.ObjectID) error {
|
||||
if s.providerRepo == nil {
|
||||
return errors.New("provider repository unavailable")
|
||||
}
|
||||
|
||||
if s.linkRepo != nil {
|
||||
users, err := s.userRepo.ListAllUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range users {
|
||||
links, err := s.linkRepo.GetUserLinks(ctx, user.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, link := range links {
|
||||
if link.ProviderID == providerID {
|
||||
if err := s.linkRepo.DeleteLink(ctx, link.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s.providerRepo.DeleteProvider(ctx, providerID)
|
||||
}
|
||||
|
||||
// ListUsers returns all users as admin DTOs
|
||||
func (s *AdminService) ListUsers(ctx context.Context) ([]*dto.AdminUserDTO, error) {
|
||||
users, err := s.userRepo.ListAllUsers(ctx)
|
||||
|
||||
@@ -114,20 +114,7 @@ func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*
|
||||
}
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
User: dto.NewUserDTO(user),
|
||||
ExpiresIn: 3600, // 1 hour
|
||||
}, nil
|
||||
@@ -165,20 +152,7 @@ func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest) (*dto.Lo
|
||||
// Log error but don't fail the login
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
User: dto.NewUserDTO(user),
|
||||
ExpiresIn: 3600,
|
||||
}, nil
|
||||
@@ -186,6 +160,10 @@ func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest) (*dto.Lo
|
||||
|
||||
// RefreshAccessToken refreshes an access token
|
||||
func (s *AuthService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, error) {
|
||||
if s.jwtManager == nil {
|
||||
return "", errors.New("jwt refresh is unavailable")
|
||||
}
|
||||
|
||||
claims, err := s.jwtManager.VerifyRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -199,6 +177,27 @@ func (s *AuthService) RefreshAccessToken(ctx context.Context, refreshToken strin
|
||||
return s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
||||
}
|
||||
|
||||
// GetUserProfile returns profile DTO for the provided user ID.
|
||||
func (s *AuthService) GetUserProfile(ctx context.Context, userID string) (*dto.UserDTO, error) {
|
||||
objID, err := bson.ObjectIDFromHex(strings.TrimSpace(userID))
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid user id")
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetUserByID(ctx, objID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.permissionService != nil {
|
||||
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return dto.NewUserDTO(user), nil
|
||||
}
|
||||
|
||||
// RequestPasswordReset initiates password reset flow
|
||||
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
|
||||
user, err := s.userRepo.GetUserByEmail(ctx, email)
|
||||
@@ -319,6 +318,57 @@ func (s *AuthService) CreateProvider(ctx context.Context, req *dto.CreateAuthPro
|
||||
return dto.NewAuthProviderDTO(provider), nil
|
||||
}
|
||||
|
||||
// UpdateProvider updates an existing OAuth/OIDC provider.
|
||||
// If ClientSecret is empty, the existing encrypted secret is preserved.
|
||||
func (s *AuthService) UpdateProvider(ctx context.Context, providerID bson.ObjectID, req *dto.UpdateAuthProviderRequest) (*dto.AuthProviderDTO, error) {
|
||||
if s.providerRepo == nil || s.encryptor == nil {
|
||||
return nil, errors.New("provider configuration unavailable")
|
||||
}
|
||||
|
||||
existing, err := s.providerRepo.GetProviderByID(ctx, providerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
providerType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if providerType != "oidc" && providerType != "oauth2" {
|
||||
return nil, errors.New("provider type must be oidc or oauth2")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
clientID := strings.TrimSpace(req.ClientID)
|
||||
authorizationURL := strings.TrimSpace(req.AuthorizationURL)
|
||||
tokenURL := strings.TrimSpace(req.TokenURL)
|
||||
if name == "" || clientID == "" || authorizationURL == "" || tokenURL == "" {
|
||||
return nil, errors.New("missing required provider fields")
|
||||
}
|
||||
|
||||
existing.Name = name
|
||||
existing.Type = providerType
|
||||
existing.ClientID = clientID
|
||||
existing.AuthorizationURL = authorizationURL
|
||||
existing.TokenURL = tokenURL
|
||||
existing.UserInfoURL = strings.TrimSpace(req.UserInfoURL)
|
||||
existing.Scopes = normalizeScopes(req.Scopes, providerType)
|
||||
existing.IDTokenClaim = strings.TrimSpace(req.IDTokenClaim)
|
||||
existing.IsActive = req.IsActive
|
||||
|
||||
clientSecret := strings.TrimSpace(req.ClientSecret)
|
||||
if clientSecret != "" {
|
||||
encrypted, err := s.encryptor.Encrypt(clientSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing.ClientSecret = encrypted
|
||||
}
|
||||
|
||||
if err := s.providerRepo.UpdateProvider(ctx, existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dto.NewAuthProviderDTO(existing), nil
|
||||
}
|
||||
|
||||
// BuildProviderAuthorizationURL constructs a provider authorization URL.
|
||||
func (s *AuthService) BuildProviderAuthorizationURL(ctx context.Context, providerID bson.ObjectID, redirectURI, state string) (string, error) {
|
||||
flags, err := s.GetFeatureFlags(ctx)
|
||||
@@ -393,17 +443,7 @@ func (s *AuthService) CompleteProviderLogin(ctx context.Context, providerID bson
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.LoginResponse{AccessToken: accessToken, RefreshToken: refreshToken, User: dto.NewUserDTO(user), ExpiresIn: 3600}, nil
|
||||
return &dto.LoginResponse{User: dto.NewUserDTO(user), ExpiresIn: 3600}, nil
|
||||
}
|
||||
|
||||
type providerProfile struct {
|
||||
|
||||
114
backend/internal/infrastructure/auth/session.go
Normal file
114
backend/internal/infrastructure/auth/session.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// SessionData stores authenticated identity data in Redis.
|
||||
type SessionData struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// SessionManager handles Redis-backed session lifecycle operations.
|
||||
type SessionManager struct {
|
||||
redis *redis.Client
|
||||
ttl time.Duration
|
||||
prefix string
|
||||
}
|
||||
|
||||
func NewSessionManager(redisClient *redis.Client, ttl time.Duration) *SessionManager {
|
||||
if ttl <= 0 {
|
||||
ttl = 7 * 24 * time.Hour
|
||||
}
|
||||
|
||||
return &SessionManager{
|
||||
redis: redisClient,
|
||||
ttl: ttl,
|
||||
prefix: "session:",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionManager) TTL() time.Duration {
|
||||
return m.ttl
|
||||
}
|
||||
|
||||
func (m *SessionManager) CreateSession(ctx context.Context, data *SessionData) (string, error) {
|
||||
if data == nil {
|
||||
return "", errors.New("session data is required")
|
||||
}
|
||||
|
||||
sessionID, err := GenerateRandomToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := m.redis.Set(ctx, m.key(sessionID), payload, m.ttl).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) GetSession(ctx context.Context, sessionID string) (*SessionData, error) {
|
||||
if sessionID == "" {
|
||||
return nil, errors.New("session id is required")
|
||||
}
|
||||
|
||||
payload, err := m.redis.Get(ctx, m.key(sessionID)).Result()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, errors.New("session not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data SessionData
|
||||
if err := json.Unmarshal([]byte(payload), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) RefreshSession(ctx context.Context, sessionID string) error {
|
||||
if sessionID == "" {
|
||||
return errors.New("session id is required")
|
||||
}
|
||||
|
||||
if err := m.redis.Expire(ctx, m.key(sessionID), m.ttl).Err(); err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return errors.New("session not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
if sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := m.redis.Del(ctx, m.key(sessionID)).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) key(sessionID string) string {
|
||||
return m.prefix + sessionID
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
@@ -32,6 +33,33 @@ func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
|
||||
}
|
||||
|
||||
// DeleteUser handles DELETE /admin/users/{userId}
|
||||
func (h *AdminHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
targetUserID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid user id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
currentUserIDHex, err := middleware.GetUserIDFromContext(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
currentUserID, err := bson.ObjectIDFromHex(currentUserIDHex)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.adminService.DeleteUser(r.Context(), currentUserID, targetUserID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// UpdateUserGroups handles PUT /admin/users/{userId}/groups
|
||||
func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"])
|
||||
@@ -66,6 +94,22 @@ func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request)
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
// DeleteGroup handles DELETE /admin/groups/{groupId}
|
||||
func (h *AdminHandler) DeleteGroup(w http.ResponseWriter, r *http.Request) {
|
||||
groupID, err := bson.ObjectIDFromHex(mux.Vars(r)["groupId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid group id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.adminService.DeleteGroup(r.Context(), groupID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListGroups handles GET /admin/groups
|
||||
func (h *AdminHandler) ListGroups(w http.ResponseWriter, r *http.Request) {
|
||||
groups, err := h.adminService.ListGroups(r.Context())
|
||||
@@ -292,3 +336,19 @@ func (h *AdminHandler) UpdateFeatureFlags(w http.ResponseWriter, r *http.Request
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(flags)
|
||||
}
|
||||
|
||||
// DeleteProvider handles DELETE /admin/auth/providers/{providerId}
|
||||
func (h *AdminHandler) DeleteProvider(w http.ResponseWriter, r *http.Request) {
|
||||
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid provider id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.adminService.DeleteProvider(r.Context(), providerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -18,15 +17,19 @@ import (
|
||||
// AuthHandler handles authentication endpoints
|
||||
type AuthHandler struct {
|
||||
authService *services.AuthService
|
||||
sessionManager *auth.SessionManager
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
|
||||
func NewAuthHandler(authService *services.AuthService, sessionManager *auth.SessionManager) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
sessionManager: sessionManager,
|
||||
}
|
||||
}
|
||||
|
||||
const sessionCookieName = "session_id"
|
||||
|
||||
// Register handles user registration
|
||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -56,6 +59,11 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.setSessionCookie(w, r, response.User); err != nil {
|
||||
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@@ -79,16 +87,10 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Set secure HTTP-only cookie for refresh token
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "refresh_token",
|
||||
Value: response.RefreshToken,
|
||||
Path: "/",
|
||||
MaxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
HttpOnly: true,
|
||||
Secure: isSecureRequest(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
if err := h.setSessionCookie(w, r, response.User); err != nil {
|
||||
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
@@ -96,15 +98,12 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Logout handles user logout
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
// Clear refresh token cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "refresh_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: isSecureRequest(r),
|
||||
})
|
||||
sessionCookie, err := r.Cookie(sessionCookieName)
|
||||
if err == nil {
|
||||
_ = h.sessionManager.DeleteSession(r.Context(), sessionCookie.Value)
|
||||
}
|
||||
|
||||
h.clearSessionCookie(w, r)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"})
|
||||
@@ -141,6 +140,30 @@ func (h *AuthHandler) CreateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(provider)
|
||||
}
|
||||
|
||||
// UpdateProvider updates an existing OAuth/OIDC provider configuration.
|
||||
func (h *AuthHandler) UpdateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid provider ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateAuthProviderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := h.authService.UpdateProvider(r.Context(), providerID, &req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(provider)
|
||||
}
|
||||
|
||||
// StartProviderLogin redirects the browser to the selected provider.
|
||||
func (h *AuthHandler) StartProviderLogin(w http.ResponseWriter, r *http.Request) {
|
||||
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
|
||||
@@ -191,7 +214,7 @@ func (h *AuthHandler) CompleteProviderLogin(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
response, err := h.authService.CompleteProviderLogin(r.Context(), providerID, r.URL.Query().Get("code"), buildBackendURL(r, "/api/v1/auth/providers/"+providerID.Hex()+"/callback"))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, buildFrontendLoginURL("oauth_error", err.Error(), "", nil), http.StatusFound)
|
||||
http.Redirect(w, r, buildFrontendLoginURL("oauth_error", err.Error()), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -205,17 +228,12 @@ func (h *AuthHandler) CompleteProviderLogin(w http.ResponseWriter, r *http.Reque
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "refresh_token",
|
||||
Value: response.RefreshToken,
|
||||
Path: "/",
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
HttpOnly: true,
|
||||
Secure: isSecureRequest(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
if err := h.setSessionCookie(w, r, response.User); err != nil {
|
||||
http.Redirect(w, r, buildFrontendLoginURL("oauth_error", "Failed to create session"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, buildFrontendLoginURL("oauth_success", "", response.AccessToken, response.User), http.StatusFound)
|
||||
http.Redirect(w, r, buildFrontendLoginURL("oauth_success", ""), http.StatusFound)
|
||||
}
|
||||
|
||||
// RefreshToken handles token refresh
|
||||
@@ -225,23 +243,57 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get refresh token from cookie
|
||||
cookie, err := r.Cookie("refresh_token")
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
http.Error(w, "Refresh token not found", http.StatusUnauthorized)
|
||||
http.Error(w, "Session not found", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := h.authService.RefreshAccessToken(r.Context(), cookie.Value)
|
||||
sessionData, err := h.sessionManager.GetSession(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
|
||||
http.Error(w, "Invalid session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := h.sessionManager.RefreshSession(r.Context(), cookie.Value); err == nil {
|
||||
http.SetCookie(w, h.newSessionCookie(r, cookie.Value))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": accessToken,
|
||||
"expires_in": 3600,
|
||||
"user": sessionData,
|
||||
"expires_in": int(h.sessionManager.TTL().Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// Me returns the currently authenticated user profile.
|
||||
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
sessionCookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
sessionData, err := h.sessionManager.GetSession(r.Context(), sessionCookie.Value)
|
||||
if err != nil {
|
||||
h.clearSessionCookie(w, r)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.GetUserProfile(r.Context(), sessionData.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sessionManager.RefreshSession(r.Context(), sessionCookie.Value); err == nil {
|
||||
http.SetCookie(w, h.newSessionCookie(r, sessionCookie.Value))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"user": user,
|
||||
"expires_in": int(h.sessionManager.TTL().Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -268,7 +320,7 @@ func buildBackendURL(r *http.Request, path string) string {
|
||||
return scheme + "://" + r.Host + path
|
||||
}
|
||||
|
||||
func buildFrontendLoginURL(status, message, accessToken string, user *dto.UserDTO) string {
|
||||
func buildFrontendLoginURL(status, message string) string {
|
||||
frontendURL := os.Getenv("FRONTEND_URL")
|
||||
if frontendURL == "" {
|
||||
frontendURL = "http://localhost:5173"
|
||||
@@ -286,14 +338,48 @@ func buildFrontendLoginURL(status, message, accessToken string, user *dto.UserDT
|
||||
if message != "" {
|
||||
query.Set("message", message)
|
||||
}
|
||||
if accessToken != "" {
|
||||
query.Set("access_token", accessToken)
|
||||
}
|
||||
if user != nil {
|
||||
payload, _ := json.Marshal(user)
|
||||
query.Set("user_json", string(payload))
|
||||
query.Set("user", base64.RawURLEncoding.EncodeToString(payload))
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func (h *AuthHandler) setSessionCookie(w http.ResponseWriter, r *http.Request, user *dto.UserDTO) error {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sessionID, err := h.sessionManager.CreateSession(r.Context(), &auth.SessionData{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
http.SetCookie(w, h.newSessionCookie(r, sessionID))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) newSessionCookie(r *http.Request, sessionID string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: int(h.sessionManager.TTL().Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: isSecureRequest(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: isSecureRequest(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,12 +21,14 @@ const (
|
||||
// AuthMiddleware verifies JWT tokens
|
||||
type AuthMiddleware struct {
|
||||
jwtManager *auth.JWTManager
|
||||
sessionManager *auth.SessionManager
|
||||
}
|
||||
|
||||
// NewAuthMiddleware creates a new auth middleware
|
||||
func NewAuthMiddleware(jwtManager *auth.JWTManager) *AuthMiddleware {
|
||||
func NewAuthMiddleware(jwtManager *auth.JWTManager, sessionManager *auth.SessionManager) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
jwtManager: jwtManager,
|
||||
sessionManager: sessionManager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,16 +43,23 @@ func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract token from Authorization header.
|
||||
// For GET /files/object, also accept ?token= so markdown images render in-browser.
|
||||
if sessionCookie, err := r.Cookie("session_id"); err == nil && sessionCookie.Value != "" {
|
||||
sessionData, sessionErr := m.sessionManager.GetSession(r.Context(), sessionCookie.Value)
|
||||
if sessionErr == nil {
|
||||
_ = m.sessionManager.RefreshSession(r.Context(), sessionCookie.Value)
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, sessionData.UserID)
|
||||
ctx = context.WithValue(ctx, EmailKey, sessionData.Email)
|
||||
r = r.WithContext(ctx)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to Authorization header for backwards compatibility.
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" && r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/files/object") {
|
||||
if tok := r.URL.Query().Get("token"); tok != "" {
|
||||
authHeader = "Bearer " + tok
|
||||
}
|
||||
}
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ func CORSMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Max-Age", "600")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
|
||||
@@ -44,20 +44,6 @@ http {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# API routes
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
|
||||
proxy_pass http://notely;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
proxy_pass http://notely;
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
container_name: notely-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
networks:
|
||||
- notely-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
mongodb:
|
||||
image: mongo:8.0
|
||||
container_name: notely-mongodb
|
||||
@@ -39,9 +50,15 @@ services:
|
||||
DEFAULT_ADMIN_EMAIL: ${DEFAULT_ADMIN_EMAIL}
|
||||
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME}
|
||||
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD}
|
||||
REDIS_ADDR: ${REDIS_ADDR}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
REDIS_DB: ${REDIS_DB}
|
||||
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS}
|
||||
depends_on:
|
||||
mongodb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- notely-network
|
||||
|
||||
|
||||
20
frontend/package-lock.json
generated
20
frontend/package-lock.json
generated
@@ -13,7 +13,9 @@
|
||||
"axios": "^1.4.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"dompurify": "^3.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^9.0.0",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.0"
|
||||
@@ -1433,6 +1435,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
@@ -1556,6 +1567,15 @@
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/marked-highlight": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.2.3.tgz",
|
||||
"integrity": "sha512-FCfZRxW/msZAiasCML4isYpxyQWKEEx44vOgdn5Kloae+Qc3q4XR7WjpKKf8oMLk7JP9ZCRd2vhtclJFdwxlWQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"marked": ">=4 <18"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"axios": "^1.4.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"dompurify": "^3.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^9.0.0",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.0"
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-auto d-flex align-items-center">
|
||||
<div v-if="!selectedNote" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode">
|
||||
<div v-if="!selectedNote || isSearchRoute" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode">
|
||||
<button
|
||||
type="button"
|
||||
class="btn action-button"
|
||||
@@ -140,7 +140,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="canEditNotes && selectedNote && !isEditingNote"
|
||||
v-if="canEditNotes && selectedNote && !isEditingNote && !isSearchRoute"
|
||||
class="btn btn-outline-secondary me-2 action-button"
|
||||
aria-label="Edit note"
|
||||
title="Edit note"
|
||||
@@ -150,7 +150,7 @@
|
||||
<span class="action-label">Edit Note</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canShareSelectedNote && !isEditingNote"
|
||||
v-if="canShareSelectedNote && !isEditingNote && !isSearchRoute"
|
||||
class="btn btn-outline-primary me-2 action-button"
|
||||
:aria-label="shareCopied ? 'Link copied' : 'Share note'"
|
||||
:title="shareCopied ? 'Link copied' : 'Share note'"
|
||||
@@ -169,8 +169,18 @@
|
||||
|
||||
<!-- Note Editor or Note List -->
|
||||
<div class="content p-4">
|
||||
<SearchResultsPage
|
||||
v-if="isSearchRoute"
|
||||
:notes="searchResults"
|
||||
:query="searchQuery"
|
||||
:current-page="searchPage"
|
||||
:page-size="searchPageSize"
|
||||
:view-mode="noteViewMode"
|
||||
@select-note="selectSearchResultNote"
|
||||
@page-change="setSearchPage"
|
||||
/>
|
||||
<NoteEditor
|
||||
v-if="selectedNote && isEditingNote"
|
||||
v-else-if="selectedNote && isEditingNote"
|
||||
:note="selectedNote"
|
||||
:category-options="categoryOptions"
|
||||
:can-delete="canDeleteNotes"
|
||||
@@ -205,7 +215,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentUser && isAdminRoute" class="container py-4">
|
||||
<div v-else-if="currentUser && isAdminRoute" class="admin-route-view">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
@@ -278,6 +288,7 @@ import CategoryTree from "./components/CategoryTree.vue";
|
||||
import NoteEditor from "./components/NoteEditor.vue";
|
||||
import NoteViewer from "./components/NoteViewer.vue";
|
||||
import NoteList from "./components/NoteList.vue";
|
||||
import SearchResultsPage from "./components/SearchResultsPage.vue";
|
||||
import CreateSpaceModal from "./components/CreateSpaceModal.vue";
|
||||
import CreateCategoryModal from "./components/CreateCategoryModal.vue";
|
||||
import CreateNoteModal from "./components/CreateNoteModal.vue";
|
||||
@@ -319,10 +330,20 @@ const unlockingNote = ref(false);
|
||||
|
||||
const currentUser = computed(() => authStore.user);
|
||||
const isAdminRoute = computed(() => route.path === "/admin");
|
||||
const isSearchRoute = computed(() => route.path === "/search");
|
||||
const isPublicRoute = computed(() => route.path.startsWith("/s/"));
|
||||
const isAuthRoute = computed(() => route.path === "/login" || route.path === "/register");
|
||||
const spaces = computed(() => spaceStore.spaces);
|
||||
const currentSpace = computed(() => spaceStore.currentSpace);
|
||||
const searchResults = computed(() => sortNotesByPriority(spaceStore.searchResults));
|
||||
const searchPageSize = 12;
|
||||
const searchPage = computed(() => {
|
||||
const pageValue = Number.parseInt(route.query.page || "1", 10);
|
||||
if (Number.isNaN(pageValue) || pageValue < 1) {
|
||||
return 1;
|
||||
}
|
||||
return pageValue;
|
||||
});
|
||||
const categoryTree = computed(() => spaceStore.categoryTree);
|
||||
const canCreateSpaces = computed(() => authStore.hasPermission("space.create"));
|
||||
const canCreateCategories = computed(() => authStore.hasSpacePermission(currentSpace.value, "category.create"));
|
||||
@@ -382,7 +403,7 @@ const canLoadMoreMainNotes = computed(() => {
|
||||
if (selectedCategory.value || selectedNote.value) {
|
||||
return false;
|
||||
}
|
||||
if (searchQuery.value.trim()) {
|
||||
if (isSearchRoute.value) {
|
||||
return false;
|
||||
}
|
||||
return spaceStore.notesHasMore;
|
||||
@@ -411,6 +432,10 @@ const openSpaceHome = () => {
|
||||
unlockPassword.value = "";
|
||||
unlockError.value = "";
|
||||
searchQuery.value = "";
|
||||
spaceStore.clearSearchResults();
|
||||
if (route.path !== "/") {
|
||||
router.push("/");
|
||||
}
|
||||
if (currentSpace.value?.id) {
|
||||
spaceStore.fetchNotes(currentSpace.value.id, { reset: true });
|
||||
}
|
||||
@@ -425,6 +450,21 @@ const breadcrumbItems = computed(() => {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isSearchRoute.value) {
|
||||
return [
|
||||
{
|
||||
label: currentSpace.value.name,
|
||||
clickable: true,
|
||||
onClick: openSpaceHome,
|
||||
},
|
||||
{
|
||||
label: searchQuery.value.trim() ? `Search: ${searchQuery.value.trim()}` : "Search",
|
||||
clickable: false,
|
||||
onClick: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: currentSpace.value.name,
|
||||
@@ -527,6 +567,30 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
[() => route.path, () => route.query.q, () => currentSpace.value?.id],
|
||||
async ([path, routeQuery, spaceId]) => {
|
||||
if (path !== "/search") {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedNote.value = null;
|
||||
selectedCategory.value = null;
|
||||
isEditingNote.value = false;
|
||||
|
||||
const q = typeof routeQuery === "string" ? routeQuery.trim() : "";
|
||||
searchQuery.value = q;
|
||||
|
||||
if (!spaceId || !q) {
|
||||
spaceStore.clearSearchResults();
|
||||
return;
|
||||
}
|
||||
|
||||
await spaceStore.searchNotes(q);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => selectedNote.value?.id,
|
||||
() => {
|
||||
@@ -683,11 +747,53 @@ const selectCategory = (category) => {
|
||||
};
|
||||
|
||||
const performSearch = async () => {
|
||||
if (searchQuery.value.trim()) {
|
||||
await spaceStore.searchNotes(searchQuery.value);
|
||||
} else if (currentSpace.value?.id) {
|
||||
const q = searchQuery.value.trim();
|
||||
if (!q) {
|
||||
spaceStore.clearSearchResults();
|
||||
if (route.path !== "/") {
|
||||
await router.push("/");
|
||||
}
|
||||
if (currentSpace.value?.id) {
|
||||
await spaceStore.fetchNotes(currentSpace.value.id, { reset: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.path !== "/search" || route.query.q !== q || route.query.page !== "1") {
|
||||
await router.push({
|
||||
path: "/search",
|
||||
query: {
|
||||
q,
|
||||
page: "1",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await spaceStore.searchNotes(q);
|
||||
}
|
||||
};
|
||||
|
||||
const setSearchPage = async (page) => {
|
||||
const q = typeof route.query.q === "string" ? route.query.q : "";
|
||||
if (!q) {
|
||||
return;
|
||||
}
|
||||
await router.push({
|
||||
path: "/search",
|
||||
query: {
|
||||
q,
|
||||
page: String(page),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectSearchResultNote = async (note) => {
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
await selectNote(note);
|
||||
if (route.path === "/search") {
|
||||
router.push("/");
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreMainNotes = async () => {
|
||||
@@ -965,6 +1071,16 @@ const logout = () => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-route-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-navbar {
|
||||
display: grid;
|
||||
|
||||
@@ -25,6 +25,70 @@ body,
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
font-weight: 600;
|
||||
background: #f3f6fb;
|
||||
}
|
||||
|
||||
.markdown-body tr:nth-child(even) td {
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.markdown-body table code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 4px solid #748ffc;
|
||||
background: #f8f9ff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.markdown-body blockquote > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 0.95em;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.35rem;
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
125
frontend/src/components/AdminGroupModal.vue
Normal file
125
frontend/src/components/AdminGroupModal.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ mode === "create" ? "Create Group" : "Edit Group" }}</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||
</div>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Group name</label>
|
||||
<input v-model="form.name" class="form-control" type="text" required :disabled="isSystemGroup" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input v-model="form.description" class="form-control" type="text" :disabled="isSystemGroup" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Permissions (one per line)</label>
|
||||
<textarea
|
||||
v-model="form.permissionsText"
|
||||
class="form-control permissions-textarea"
|
||||
rows="10"
|
||||
placeholder="space.create space.project_docs.category.create space.project_docs.*"
|
||||
:disabled="isSystemGroup"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Cancel</button>
|
||||
<button v-if="!isSystemGroup" type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? "Saving..." : mode === "create" ? "Create Group" : "Save Changes" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: "create",
|
||||
},
|
||||
group: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isSystemGroup: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
submitting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "submit"]);
|
||||
|
||||
const form = ref({
|
||||
name: "",
|
||||
description: "",
|
||||
permissionsText: "",
|
||||
});
|
||||
|
||||
const hydrateForm = () => {
|
||||
form.value = {
|
||||
name: props.group?.name || "",
|
||||
description: props.group?.description || "",
|
||||
permissionsText: (props.group?.permissions || []).join("\n"),
|
||||
};
|
||||
};
|
||||
|
||||
watch(() => [props.mode, props.group], hydrateForm, { immediate: true });
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("submit", {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
permissionsText: form.value.permissionsText,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-modal {
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
z-index: 1990;
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.permissions-textarea {
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-modal {
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 0.75rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
187
frontend/src/components/AdminProviderModal.vue
Normal file
187
frontend/src/components/AdminProviderModal.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ mode === "create" ? "Add Identity Provider" : "Edit Identity Provider" }}</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||
</div>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Display Name <span class="text-danger">*</span></label>
|
||||
<input v-model="form.name" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Provider Type <span class="text-danger">*</span></label>
|
||||
<select v-model="form.type" class="form-select">
|
||||
<option value="oidc">OIDC</option>
|
||||
<option value="oauth2">OAuth2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Client ID <span class="text-danger">*</span></label>
|
||||
<input v-model="form.client_id" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">
|
||||
Client Secret
|
||||
<span v-if="mode === 'create'" class="text-danger">*</span>
|
||||
<span v-else class="text-muted small">(leave blank to keep existing)</span>
|
||||
</label>
|
||||
<input v-model="form.client_secret" type="password" class="form-control" :required="mode === 'create'" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Authorization URL <span class="text-danger">*</span></label>
|
||||
<input v-model="form.authorization_url" type="url" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Token URL <span class="text-danger">*</span></label>
|
||||
<input v-model="form.token_url" type="url" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">UserInfo URL</label>
|
||||
<input v-model="form.userinfo_url" type="url" class="form-control" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">ID Token Claim</label>
|
||||
<input v-model="form.id_token_claim" type="text" class="form-control" placeholder="id_token" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Scopes</label>
|
||||
<input v-model="form.scopes" type="text" class="form-control" placeholder="openid, profile, email" />
|
||||
<div class="form-text">Comma-separated list of OAuth scopes.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input id="provider-active" v-model="form.is_active" type="checkbox" class="form-check-input" />
|
||||
<label for="provider-active" class="form-check-label">Provider is active</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? "Saving..." : mode === "create" ? "Add Provider" : "Save Changes" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: "create",
|
||||
},
|
||||
provider: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
submitting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "submit"]);
|
||||
|
||||
const form = ref({
|
||||
name: "",
|
||||
type: "oidc",
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
userinfo_url: "",
|
||||
id_token_claim: "id_token",
|
||||
scopes: "openid, profile, email",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const hydrateForm = () => {
|
||||
if (props.mode === "edit" && props.provider) {
|
||||
form.value = {
|
||||
name: props.provider.name || "",
|
||||
type: props.provider.type || "oidc",
|
||||
client_id: props.provider.client_id || "",
|
||||
client_secret: "",
|
||||
authorization_url: props.provider.authorization_url || "",
|
||||
token_url: props.provider.token_url || "",
|
||||
userinfo_url: props.provider.userinfo_url || "",
|
||||
id_token_claim: props.provider.id_token_claim || "id_token",
|
||||
scopes: (props.provider.scopes || []).join(", "),
|
||||
is_active: props.provider.is_active ?? true,
|
||||
};
|
||||
} else {
|
||||
form.value = {
|
||||
name: "",
|
||||
type: "oidc",
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
userinfo_url: "",
|
||||
id_token_claim: "id_token",
|
||||
scopes: "openid, profile, email",
|
||||
is_active: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => [props.mode, props.provider], hydrateForm, { immediate: true });
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("submit", {
|
||||
name: form.value.name,
|
||||
type: form.value.type,
|
||||
client_id: form.value.client_id,
|
||||
client_secret: form.value.client_secret,
|
||||
authorization_url: form.value.authorization_url,
|
||||
token_url: form.value.token_url,
|
||||
userinfo_url: form.value.userinfo_url,
|
||||
id_token_claim: form.value.id_token_claim,
|
||||
scopes: form.value.scopes
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
is_active: form.value.is_active,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-modal {
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
z-index: 1990;
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-modal {
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered" role="document">
|
||||
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Space</h5>
|
||||
@@ -96,7 +96,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
@@ -252,3 +252,30 @@ const deleteSpace = async () => {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-modal {
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
z-index: 1990;
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-modal {
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 0.75rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
111
frontend/src/components/AdminUserModal.vue
Normal file
111
frontend/src/components/AdminUserModal.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit User</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||
</div>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input class="form-control" :value="user?.username || ''" type="text" disabled />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input class="form-control" :value="user?.email || ''" type="text" disabled />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Status</label>
|
||||
<input class="form-control" :value="user?.is_active ? 'Active' : 'Inactive'" type="text" disabled />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Groups</label>
|
||||
<select v-model="groupIds" class="form-select" multiple>
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">
|
||||
{{ group.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="small text-muted mt-1">Ctrl/Cmd+Click for multiple groups</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? "Saving..." : "Save Changes" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
submitting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "submit"]);
|
||||
|
||||
const groupIds = ref([]);
|
||||
|
||||
watch(
|
||||
() => props.user,
|
||||
(user) => {
|
||||
groupIds.value = [...(user?.group_ids || [])];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("submit", { group_ids: groupIds.value });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-modal {
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
z-index: 1990;
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-modal {
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 0.75rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,6 @@
|
||||
<div class="note-editor">
|
||||
<div class="editor-toolbar mb-3">
|
||||
<button class="btn btn-sm btn-primary" @click="saveNote">Save</button>
|
||||
<button v-if="canDelete" class="btn btn-sm btn-danger ms-2" @click="confirmDelete">Delete</button>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-2" @click="emit('cancel')">Cancel</button>
|
||||
<button
|
||||
v-if="fileExplorerEnabled"
|
||||
@@ -32,7 +31,7 @@
|
||||
|
||||
<div :class="showFileExplorer ? 'col-12 col-md-4 mt-3 mt-md-0' : 'col-12 col-md-6 mt-3 mt-md-0'">
|
||||
<div class="preview-pane border rounded p-3">
|
||||
<div v-html="renderedMarkdown"></div>
|
||||
<div class="markdown-body" v-html="renderedMarkdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,17 +81,24 @@
|
||||
</select>
|
||||
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
|
||||
</div>
|
||||
|
||||
<section v-if="canDelete && editingNote.id" class="danger-zone mt-4" aria-labelledby="danger-zone-title">
|
||||
<h3 id="danger-zone-title" class="danger-zone-title mb-2">Danger Zone</h3>
|
||||
<p class="danger-zone-copy mb-3">Deleting this note is permanent and cannot be undone.</p>
|
||||
<button class="btn btn-danger" type="button" @click="confirmDelete">
|
||||
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||
Delete Note
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useSettingsStore } from "../stores/settingsStore";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
import { preprocessMarkdown } from "../utils/markdown.js";
|
||||
import { renderMarkdown } from "../utils/markdown.js";
|
||||
import FileExplorer from "./FileExplorer.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -116,7 +122,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(["save", "delete", "cancel"]);
|
||||
const settingsStore = useSettingsStore();
|
||||
const authStore = useAuthStore();
|
||||
const publicSharingEnabled = ref(true);
|
||||
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
|
||||
|
||||
@@ -132,18 +137,8 @@ const saveState = ref("saved");
|
||||
const saveStateTimeout = ref(null);
|
||||
|
||||
const renderedMarkdown = computed(() => {
|
||||
const html = marked.parse(preprocessMarkdown(editingNote.value.content || ""));
|
||||
let clean = DOMPurify.sanitize(html);
|
||||
// Inject access token into space file API URLs so images render without a separate JS fetch
|
||||
const token = authStore.accessToken;
|
||||
if (token && props.spaceId) {
|
||||
clean = clean.replace(/((?:src|href)=["'])([^"']*\/api\/v1\/spaces\/[^"']*\/files\/object[^"']*)(["'])/g, (_, attr, url, quote) => {
|
||||
if (url.includes("token=")) return attr + url + quote;
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
return `${attr}${url}${sep}token=${encodeURIComponent(token)}${quote}`;
|
||||
});
|
||||
}
|
||||
return clean;
|
||||
const html = renderMarkdown(editingNote.value.content || "");
|
||||
return DOMPurify.sanitize(html);
|
||||
});
|
||||
|
||||
const saveStatusLabel = computed(() => {
|
||||
@@ -294,7 +289,7 @@ onMounted(async () => {
|
||||
|
||||
.editor-textarea {
|
||||
font-family: "Courier New", monospace;
|
||||
min-height: 400px;
|
||||
min-height: 600px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@@ -333,4 +328,22 @@ onMounted(async () => {
|
||||
overflow-y: auto;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
padding: 1rem;
|
||||
border: 1px solid #f3b5b5;
|
||||
border-radius: 0.75rem;
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.danger-zone-title {
|
||||
color: #9f1c1c;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.danger-zone-copy {
|
||||
color: #7a2727;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,10 +30,8 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
import { preprocessMarkdown } from "../utils/markdown.js";
|
||||
import { renderMarkdown } from "../utils/markdown.js";
|
||||
|
||||
const props = defineProps({
|
||||
note: {
|
||||
@@ -50,20 +48,9 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const renderedMarkdown = computed(() => {
|
||||
const html = marked.parse(preprocessMarkdown(props.note.content || ""));
|
||||
let clean = DOMPurify.sanitize(html);
|
||||
const token = authStore.accessToken;
|
||||
if (token && props.spaceId) {
|
||||
clean = clean.replace(/((?:src|href)=["'])([^"']*\/api\/v1\/spaces\/[^"']*\/files\/object[^"']*)(["'])/g, (_, attr, url, quote) => {
|
||||
if (url.includes("token=")) return attr + url + quote;
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
return `${attr}${url}${sep}token=${encodeURIComponent(token)}${quote}`;
|
||||
});
|
||||
}
|
||||
return clean;
|
||||
const html = renderMarkdown(props.note.content || "");
|
||||
return DOMPurify.sanitize(html);
|
||||
});
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
|
||||
154
frontend/src/components/SearchResultsPage.vue
Normal file
154
frontend/src/components/SearchResultsPage.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<section class="search-results-page">
|
||||
<header class="search-results-header">
|
||||
<h2>Search Results</h2>
|
||||
<p v-if="query" class="search-meta">{{ totalResults }} matches for "{{ query }}"</p>
|
||||
<p v-else class="search-meta">Type in the top bar and press Enter to search notes.</p>
|
||||
</header>
|
||||
|
||||
<div v-if="!query" class="empty-state">
|
||||
<i class="mdi mdi-magnify empty-state-icon" aria-hidden="true"></i>
|
||||
<h3>Start your search</h3>
|
||||
<p>Use a title, content keyword, or tag to find matching notes in the selected space.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="totalResults === 0" class="empty-state">
|
||||
<i class="mdi mdi-file-search-outline empty-state-icon" aria-hidden="true"></i>
|
||||
<h3>No matching notes</h3>
|
||||
<p>Try different keywords or a shorter phrase.</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<NoteList :notes="paginatedNotes" :view-mode="viewMode" @select-note="emit('select-note', $event)" />
|
||||
|
||||
<nav v-if="totalPages > 1" class="pagination-bar" aria-label="Search result pages">
|
||||
<button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">Previous</button>
|
||||
<span class="page-indicator">Page {{ currentPage }} of {{ totalPages }}</span>
|
||||
<button class="btn btn-outline-secondary" :disabled="currentPage >= totalPages" @click="goToPage(currentPage + 1)">Next</button>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import NoteList from "./NoteList.vue";
|
||||
|
||||
const props = defineProps({
|
||||
query: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
notes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 12,
|
||||
},
|
||||
viewMode: {
|
||||
type: String,
|
||||
default: "grid",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select-note", "page-change"]);
|
||||
|
||||
const totalResults = computed(() => props.notes.length);
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / props.pageSize)));
|
||||
|
||||
const normalizedPage = computed(() => {
|
||||
if (!Number.isFinite(props.currentPage) || props.currentPage < 1) {
|
||||
return 1;
|
||||
}
|
||||
return Math.min(props.currentPage, totalPages.value);
|
||||
});
|
||||
|
||||
const paginatedNotes = computed(() => {
|
||||
const start = (normalizedPage.value - 1) * props.pageSize;
|
||||
return props.notes.slice(start, start + props.pageSize);
|
||||
});
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page < 1 || page > totalPages.value) {
|
||||
return;
|
||||
}
|
||||
emit("page-change", page);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-results-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-results-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-results-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #223149;
|
||||
}
|
||||
|
||||
.search-meta {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #5b6f8b;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
color: #4f637d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 48vh;
|
||||
border: 1px dashed #cfdae9;
|
||||
border-radius: 14px;
|
||||
background: radial-gradient(circle at 20% 20%, #f2f9ff 0%, #edf2ff 70%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 4.2rem;
|
||||
color: #60789a;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 0;
|
||||
color: #223149;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0.6rem 0 0;
|
||||
color: #5b6f8b;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pagination-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import router from "./router";
|
||||
import App from "./App.vue";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "@mdi/font/css/materialdesignicons.min.css";
|
||||
import "highlight.js/styles/github-dark.min.css";
|
||||
import "./assets/styles/main.css";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
<template>
|
||||
<div class="admin-page">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
|
||||
<div class="admin-topbar d-flex justify-content-between align-items-center mb-0 gap-2">
|
||||
<button class="btn btn-outline-secondary d-md-none" type="button" aria-label="Open admin navigation" @click="showMobileSidebar = true">
|
||||
<i class="mdi mdi-menu" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="d-flex align-items-start gap-2">
|
||||
<div>
|
||||
<h2 class="mb-1">Admin Panel</h2>
|
||||
<p class="text-muted mb-0">Manage users, groups, spaces, and identity providers.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary" @click="router.push('/')">Back to Notes</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger">{{ error }}</div>
|
||||
<div v-if="successMessage" class="alert alert-success">{{ successMessage }}</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" :class="{ active: activeTab === 'groups' }" @click="activeTab = 'groups'">Groups</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" :class="{ active: activeTab === 'spaces' }" @click="activeTab = 'spaces'">Spaces</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" :class="{ active: activeTab === 'providers' }" @click="activeTab = 'providers'">Identity Providers</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" :class="{ active: activeTab === 'featureFlags' }" @click="activeTab = 'featureFlags'">Feature Flags</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="admin-shell">
|
||||
<div v-if="showMobileSidebar" class="admin-sidebar-backdrop" @click="showMobileSidebar = false"></div>
|
||||
|
||||
<aside class="admin-sidebar" :class="{ open: showMobileSidebar }">
|
||||
<div class="admin-sidebar-inner">
|
||||
<div class="d-flex justify-content-between align-items-center px-2 py-1 d-md-none">
|
||||
<h6 class="mb-0">Admin Sections</h6>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="showMobileSidebar = false"></button>
|
||||
</div>
|
||||
|
||||
<nav class="nav nav-pills flex-column gap-1 admin-nav">
|
||||
<button v-for="tab in adminTabs" :key="tab.id" class="nav-link text-start" :class="{ active: activeTab === tab.id }" @click="selectTab(tab.id)">
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="admin-content">
|
||||
<section v-if="activeTab === 'users'" class="admin-section card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
@@ -38,48 +44,39 @@
|
||||
|
||||
<div v-if="loadingUsers" class="text-muted small">Loading users...</div>
|
||||
<div v-else-if="users.length === 0" class="border rounded p-3 text-muted">No users found.</div>
|
||||
<div v-else class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Groups</th>
|
||||
<th>Status</th>
|
||||
<th>Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id">
|
||||
<td>{{ u.username }}</td>
|
||||
<td class="text-muted small">{{ u.email }}</td>
|
||||
<td style="min-width: 260px">
|
||||
<select
|
||||
class="form-select form-select-sm"
|
||||
multiple
|
||||
:value="u.group_ids || []"
|
||||
@change="
|
||||
updateUserGroups(
|
||||
u.id,
|
||||
Array.from($event.target.selectedOptions).map((option) => option.value),
|
||||
)
|
||||
"
|
||||
>
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">
|
||||
{{ group.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="small text-muted mt-1">Ctrl/Cmd+Click for multiple groups</div>
|
||||
</td>
|
||||
<td>
|
||||
<div v-else class="list-group users-list">
|
||||
<div v-for="u in users" :key="u.id" class="list-group-item user-row">
|
||||
<div class="user-row-main">
|
||||
<div class="user-name-line">
|
||||
<span class="fw-semibold user-name">{{ u.username }}</span>
|
||||
<span class="badge" :class="u.is_active ? 'text-bg-success' : 'text-bg-secondary'">
|
||||
{{ u.is_active ? "Active" : "Inactive" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted small">{{ formatDate(u.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="user-meta-grid">
|
||||
<div class="user-meta-item">
|
||||
<div class="user-meta-label">Email</div>
|
||||
<div class="user-meta-value">{{ u.email }}</div>
|
||||
</div>
|
||||
<div class="user-meta-item">
|
||||
<div class="user-meta-label">Joined</div>
|
||||
<div class="user-meta-value">{{ formatDate(u.created_at) }}</div>
|
||||
</div>
|
||||
<div class="user-meta-item user-meta-item-groups">
|
||||
<div class="user-meta-label">Groups</div>
|
||||
<div class="user-meta-value">{{ getUserGroupSummary(u) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-row-actions">
|
||||
<div class="d-flex gap-2 user-actions-stack">
|
||||
<button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="deleteUser(u)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -106,7 +103,10 @@
|
||||
<div class="small text-muted">{{ group.description || "No description" }}</div>
|
||||
<div class="small text-muted">{{ (group.permissions || []).length }} permission{{ (group.permissions || []).length === 1 ? "" : "s" }}</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="deleteGroup(group)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,77 +140,26 @@
|
||||
|
||||
<section v-if="activeTab === 'providers'" class="admin-section card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h5 class="mb-0">Configured Providers</h5>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0">Identity Providers</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingProviders" @click="loadProviders">Refresh</button>
|
||||
<button class="btn btn-sm btn-primary" @click="openCreateProviderModal"><i class="mdi mdi-plus me-1" aria-hidden="true"></i>Add Provider</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingProviders" class="text-muted small">Loading providers...</div>
|
||||
<div v-else-if="providers.length === 0" class="border rounded p-3 text-muted">No providers configured yet.</div>
|
||||
<div v-else class="list-group mb-3">
|
||||
<div v-else class="list-group">
|
||||
<div v-for="provider in providers" :key="provider.id" class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="fw-semibold">{{ provider.name }}</div>
|
||||
<div class="small text-muted">{{ provider.type.toUpperCase() }} · {{ provider.scopes.join(", ") }}</div>
|
||||
<div class="small text-muted">Callback: {{ buildCallbackUrl(provider.id) }}</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="fw-semibold">{{ provider.name }}</span>
|
||||
</div>
|
||||
<span class="badge" :class="provider.is_active ? 'text-bg-success' : 'text-bg-secondary'">
|
||||
{{ provider.is_active ? "Active" : "Disabled" }}
|
||||
</span>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="openEditProviderModal(provider)">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="mb-2">Add Provider</h6>
|
||||
<form class="row g-3" @submit.prevent="createProvider">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Display Name</label>
|
||||
<input v-model="providerForm.name" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Provider Type</label>
|
||||
<select v-model="providerForm.type" class="form-select">
|
||||
<option value="oidc">OIDC</option>
|
||||
<option value="oauth2">OAuth2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Client ID</label>
|
||||
<input v-model="providerForm.client_id" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Client Secret</label>
|
||||
<input v-model="providerForm.client_secret" type="password" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Authorization URL</label>
|
||||
<input v-model="providerForm.authorization_url" type="url" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Token URL</label>
|
||||
<input v-model="providerForm.token_url" type="url" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">UserInfo URL</label>
|
||||
<input v-model="providerForm.userinfo_url" type="url" class="form-control" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">ID Token Field</label>
|
||||
<input v-model="providerForm.id_token_claim" type="text" class="form-control" placeholder="id_token" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Scopes</label>
|
||||
<input v-model="providerForm.scopes" type="text" class="form-control" placeholder="openid, profile, email" />
|
||||
</div>
|
||||
<div class="col-12 form-check ms-2">
|
||||
<input id="provider-active" v-model="providerForm.is_active" type="checkbox" class="form-check-input" />
|
||||
<label for="provider-active" class="form-check-label">Provider is active</label>
|
||||
</div>
|
||||
<div class="col-12 d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-primary" :disabled="submittingProvider">
|
||||
{{ submittingProvider ? "Saving..." : "Add Provider" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -305,53 +254,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminSpaceModal v-if="showSpaceModal && selectedSpace" :space="selectedSpace" :users="users" @close="showSpaceModal = false" @saved="onSpaceSaved" @deleted="onSpaceDeleted" />
|
||||
|
||||
<teleport to="body">
|
||||
<div v-if="showGroupModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeGroupModal">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ groupModalMode === "create" ? "Create Group" : "Edit Group" }}</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="closeGroupModal"></button>
|
||||
</div>
|
||||
<form @submit.prevent="submitGroupModal">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Group name</label>
|
||||
<input v-model="groupModalForm.name" class="form-control" type="text" required :disabled="isEditingSystemGroup" />
|
||||
</div>
|
||||
<AdminGroupModal
|
||||
v-if="showGroupModal"
|
||||
:mode="groupModalMode"
|
||||
:group="selectedGroup"
|
||||
:is-system-group="isEditingSystemGroup"
|
||||
:submitting="submittingGroupModal"
|
||||
@close="closeGroupModal"
|
||||
@submit="submitGroupModal"
|
||||
/>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input v-model="groupModalForm.description" class="form-control" type="text" :disabled="isEditingSystemGroup" />
|
||||
</div>
|
||||
<AdminUserModal v-if="showUserModal && selectedUser" :user="selectedUser" :groups="groups" :submitting="submittingUserModal" @close="closeUserModal" @submit="submitUserModal" />
|
||||
|
||||
<div>
|
||||
<label class="form-label">Permissions (one per line)</label>
|
||||
<textarea
|
||||
v-model="groupModalForm.permissionsText"
|
||||
class="form-control permissions-textarea"
|
||||
rows="10"
|
||||
placeholder="space.create space.project_docs.category.create space.project_docs.*"
|
||||
:disabled="isEditingSystemGroup"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="closeGroupModal">Cancel</button>
|
||||
<button v-if="!isEditingSystemGroup" type="submit" class="btn btn-primary" :disabled="submittingGroupModal">
|
||||
{{ submittingGroupModal ? "Saving..." : groupModalMode === "create" ? "Create Group" : "Save Changes" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showGroupModal" class="modal-backdrop fade show"></div>
|
||||
</teleport>
|
||||
<AdminProviderModal
|
||||
v-if="showProviderModal"
|
||||
:mode="providerModalMode"
|
||||
:provider="selectedProvider"
|
||||
:submitting="submittingProviderModal"
|
||||
@close="closeProviderModal"
|
||||
@submit="submitProviderModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -359,14 +287,34 @@ import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import apiClient from "../services/apiClient";
|
||||
import AdminSpaceModal from "../components/AdminSpaceModal.vue";
|
||||
import AdminGroupModal from "../components/AdminGroupModal.vue";
|
||||
import AdminUserModal from "../components/AdminUserModal.vue";
|
||||
import AdminProviderModal from "../components/AdminProviderModal.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const activeTab = ref("users");
|
||||
const showMobileSidebar = ref(false);
|
||||
const error = ref("");
|
||||
const successMessage = ref("");
|
||||
|
||||
const adminTabs = [
|
||||
{ id: "users", label: "Users" },
|
||||
{ id: "groups", label: "Groups" },
|
||||
{ id: "spaces", label: "Spaces" },
|
||||
{ id: "providers", label: "Identity Providers" },
|
||||
{ id: "featureFlags", label: "Feature Flags" },
|
||||
];
|
||||
|
||||
const selectTab = (tabId) => {
|
||||
activeTab.value = tabId;
|
||||
showMobileSidebar.value = false;
|
||||
};
|
||||
|
||||
const users = ref([]);
|
||||
const loadingUsers = ref(false);
|
||||
const showUserModal = ref(false);
|
||||
const submittingUserModal = ref(false);
|
||||
const selectedUser = ref(null);
|
||||
|
||||
const groups = ref([]);
|
||||
const loadingGroups = ref(false);
|
||||
@@ -374,11 +322,7 @@ const showGroupModal = ref(false);
|
||||
const groupModalMode = ref("create");
|
||||
const editingGroupId = ref("");
|
||||
const submittingGroupModal = ref(false);
|
||||
const groupModalForm = ref({
|
||||
name: "",
|
||||
description: "",
|
||||
permissionsText: "",
|
||||
});
|
||||
const selectedGroup = ref(null);
|
||||
|
||||
const spaces = ref([]);
|
||||
const loadingSpaces = ref(false);
|
||||
@@ -387,19 +331,10 @@ const selectedSpace = ref(null);
|
||||
|
||||
const providers = ref([]);
|
||||
const loadingProviders = ref(false);
|
||||
const submittingProvider = ref(false);
|
||||
const providerForm = ref({
|
||||
name: "",
|
||||
type: "oidc",
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
userinfo_url: "",
|
||||
id_token_claim: "id_token",
|
||||
scopes: "openid, profile, email",
|
||||
is_active: true,
|
||||
});
|
||||
const showProviderModal = ref(false);
|
||||
const providerModalMode = ref("create");
|
||||
const selectedProvider = ref(null);
|
||||
const submittingProviderModal = ref(false);
|
||||
|
||||
const loadingFeatureFlags = ref(false);
|
||||
const savingFeatureFlags = ref(false);
|
||||
@@ -439,8 +374,10 @@ const loadUsers = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserGroups = async (userId, groupIds) => {
|
||||
const updateUserGroups = async (userId, groupIds, options = {}) => {
|
||||
if (!options.silent) {
|
||||
clearMessages();
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.put(`/api/v1/admin/users/${userId}/groups`, { group_ids: groupIds });
|
||||
const updatedUser = response.data;
|
||||
@@ -448,26 +385,70 @@ const updateUserGroups = async (userId, groupIds) => {
|
||||
if (userIndex !== -1) {
|
||||
users.value[userIndex] = { ...users.value[userIndex], ...updatedUser };
|
||||
}
|
||||
if (!options.silent) {
|
||||
successMessage.value = "User groups updated.";
|
||||
}
|
||||
return updatedUser;
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to update user groups.";
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const resetGroupModalForm = () => {
|
||||
groupModalForm.value = {
|
||||
name: "",
|
||||
description: "",
|
||||
permissionsText: "",
|
||||
};
|
||||
const getUserGroupSummary = (user) => {
|
||||
const ids = user?.group_ids || [];
|
||||
if (!ids.length) {
|
||||
return "No groups";
|
||||
}
|
||||
const names = ids.map((groupID) => groups.value.find((group) => group.id === groupID)?.name).filter(Boolean);
|
||||
return names.length ? names.join(", ") : "No groups";
|
||||
};
|
||||
|
||||
const openEditUserModal = (user) => {
|
||||
selectedUser.value = { ...user };
|
||||
showUserModal.value = true;
|
||||
};
|
||||
|
||||
const closeUserModal = () => {
|
||||
showUserModal.value = false;
|
||||
submittingUserModal.value = false;
|
||||
selectedUser.value = null;
|
||||
};
|
||||
|
||||
const submitUserModal = async ({ group_ids }) => {
|
||||
submittingUserModal.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
await updateUserGroups(selectedUser.value.id, group_ids, { silent: true });
|
||||
successMessage.value = "User updated.";
|
||||
closeUserModal();
|
||||
} catch {
|
||||
// error message handled in updateUserGroups
|
||||
} finally {
|
||||
submittingUserModal.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (user) => {
|
||||
if (!confirm(`Delete user "${user.username}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/admin/users/${user.id}`);
|
||||
users.value = users.value.filter((item) => item.id !== user.id);
|
||||
successMessage.value = `User "${user.username}" deleted.`;
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to delete user.";
|
||||
}
|
||||
};
|
||||
|
||||
const isEditingSystemGroup = computed(() => {
|
||||
if (groupModalMode.value !== "edit") {
|
||||
return false;
|
||||
}
|
||||
const group = groups.value.find((item) => item.id === editingGroupId.value);
|
||||
return !!group?.is_system;
|
||||
return !!selectedGroup.value?.is_system;
|
||||
});
|
||||
|
||||
const splitPermissionsByNewline = (raw) =>
|
||||
@@ -479,24 +460,21 @@ const splitPermissionsByNewline = (raw) =>
|
||||
const openCreateGroupModal = () => {
|
||||
groupModalMode.value = "create";
|
||||
editingGroupId.value = "";
|
||||
resetGroupModalForm();
|
||||
selectedGroup.value = null;
|
||||
showGroupModal.value = true;
|
||||
};
|
||||
|
||||
const openEditGroupModal = (group) => {
|
||||
groupModalMode.value = "edit";
|
||||
editingGroupId.value = group.id;
|
||||
groupModalForm.value = {
|
||||
name: group.name || "",
|
||||
description: group.description || "",
|
||||
permissionsText: (group.permissions || []).join("\n"),
|
||||
};
|
||||
selectedGroup.value = { ...group };
|
||||
showGroupModal.value = true;
|
||||
};
|
||||
|
||||
const closeGroupModal = () => {
|
||||
showGroupModal.value = false;
|
||||
submittingGroupModal.value = false;
|
||||
selectedGroup.value = null;
|
||||
};
|
||||
|
||||
const loadGroups = async () => {
|
||||
@@ -512,14 +490,14 @@ const loadGroups = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const submitGroupModal = async () => {
|
||||
const submitGroupModal = async (formData) => {
|
||||
submittingGroupModal.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
const payload = {
|
||||
name: groupModalForm.value.name,
|
||||
description: groupModalForm.value.description,
|
||||
permissions: splitPermissionsByNewline(groupModalForm.value.permissionsText),
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
permissions: splitPermissionsByNewline(formData.permissionsText),
|
||||
};
|
||||
|
||||
if (groupModalMode.value === "create") {
|
||||
@@ -531,7 +509,6 @@ const submitGroupModal = async () => {
|
||||
}
|
||||
|
||||
closeGroupModal();
|
||||
resetGroupModalForm();
|
||||
await Promise.all([loadGroups(), loadUsers()]);
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || `Failed to ${groupModalMode.value === "create" ? "create" : "update"} group.`;
|
||||
@@ -540,6 +517,24 @@ const submitGroupModal = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGroup = async (group) => {
|
||||
if (group.is_system) {
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete group "${group.name}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/admin/groups/${group.id}`);
|
||||
successMessage.value = `Group "${group.name}" deleted.`;
|
||||
await Promise.all([loadGroups(), loadUsers()]);
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to delete group.";
|
||||
}
|
||||
};
|
||||
|
||||
const loadSpaces = async () => {
|
||||
loadingSpaces.value = true;
|
||||
clearMessages();
|
||||
@@ -574,21 +569,43 @@ const onSpaceDeleted = (deletedSpace) => {
|
||||
successMessage.value = `Space "${deletedSpace.name}" deleted.`;
|
||||
};
|
||||
|
||||
const buildCallbackUrl = (providerId) => `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/callback`;
|
||||
const openCreateProviderModal = () => {
|
||||
providerModalMode.value = "create";
|
||||
selectedProvider.value = null;
|
||||
showProviderModal.value = true;
|
||||
};
|
||||
|
||||
const resetProviderForm = () => {
|
||||
providerForm.value = {
|
||||
name: "",
|
||||
type: "oidc",
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
userinfo_url: "",
|
||||
id_token_claim: "id_token",
|
||||
scopes: "openid, profile, email",
|
||||
is_active: true,
|
||||
};
|
||||
const openEditProviderModal = (provider) => {
|
||||
providerModalMode.value = "edit";
|
||||
selectedProvider.value = { ...provider };
|
||||
showProviderModal.value = true;
|
||||
};
|
||||
|
||||
const closeProviderModal = () => {
|
||||
showProviderModal.value = false;
|
||||
submittingProviderModal.value = false;
|
||||
selectedProvider.value = null;
|
||||
};
|
||||
|
||||
const submitProviderModal = async (formData) => {
|
||||
submittingProviderModal.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
if (providerModalMode.value === "create") {
|
||||
await apiClient.post("/api/v1/admin/auth/providers", formData);
|
||||
successMessage.value = "Provider added.";
|
||||
} else {
|
||||
await apiClient.put(`/api/v1/admin/auth/providers/${selectedProvider.value.id}`, formData);
|
||||
successMessage.value = "Provider updated.";
|
||||
}
|
||||
|
||||
closeProviderModal();
|
||||
await loadProviders();
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || `Failed to ${providerModalMode.value === "create" ? "create" : "update"} provider.`;
|
||||
} finally {
|
||||
submittingProviderModal.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadProviders = async () => {
|
||||
@@ -604,24 +621,18 @@ const loadProviders = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const createProvider = async () => {
|
||||
submittingProvider.value = true;
|
||||
const deleteProvider = async (provider) => {
|
||||
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
try {
|
||||
await apiClient.post("/api/v1/admin/auth/providers", {
|
||||
...providerForm.value,
|
||||
scopes: providerForm.value.scopes
|
||||
.split(",")
|
||||
.map((scope) => scope.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
successMessage.value = "Provider added.";
|
||||
resetProviderForm();
|
||||
await loadProviders();
|
||||
await apiClient.delete(`/api/v1/admin/auth/providers/${provider.id}`);
|
||||
providers.value = providers.value.filter((item) => item.id !== provider.id);
|
||||
successMessage.value = `Provider "${provider.name}" deleted.`;
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to create provider.";
|
||||
} finally {
|
||||
submittingProvider.value = false;
|
||||
error.value = e.response?.data || "Failed to delete provider.";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -691,15 +702,203 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.admin-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
background: #f8f9fa;
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.admin-sidebar-inner {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-nav .nav-link {
|
||||
border-radius: 0.6rem;
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-nav .nav-link:hover {
|
||||
background: #eef2f7;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.admin-nav .nav-link.active {
|
||||
background: #212529;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.permissions-textarea {
|
||||
font-family: "Courier New", monospace;
|
||||
.users-list .list-group-item {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-row-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-actions-stack {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.user-name-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.user-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.user-meta-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #6c757d;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.user-meta-value {
|
||||
color: #495057;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.user-meta-item-groups {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.user-meta-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.user-meta-item-groups {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-shell {
|
||||
display: block;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-sidebar-backdrop {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 1400;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: min(82vw, 320px);
|
||||
z-index: 1410;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.admin-sidebar-inner {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.user-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.user-row-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-row-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-actions-stack {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -88,73 +88,33 @@ const startProviderLogin = (providerId) => {
|
||||
window.location.href = `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/start`;
|
||||
};
|
||||
|
||||
const decodeBase64Url = (value) => {
|
||||
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = normalized.length % 4;
|
||||
const padded = padding === 0 ? normalized : `${normalized}${"=".repeat(4 - padding)}`;
|
||||
return atob(padded);
|
||||
};
|
||||
|
||||
const decodeBase64UrlUTF8 = (value) => {
|
||||
const binary = decodeBase64Url(value);
|
||||
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
};
|
||||
|
||||
const readUserFromQuery = (params) => {
|
||||
const plainUserJSON = params.get("user_json");
|
||||
if (plainUserJSON) {
|
||||
return JSON.parse(plainUserJSON);
|
||||
}
|
||||
|
||||
const encodedUser = params.get("user");
|
||||
if (encodedUser) {
|
||||
return JSON.parse(decodeBase64UrlUTF8(encodedUser));
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const completeOAuthRedirect = async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const status = params.get("status");
|
||||
const accessToken = params.get("access_token") || params.get("accessToken") || params.get("token");
|
||||
|
||||
if (status === "oauth_error") {
|
||||
error.value = params.get("message") || "Provider sign-in failed.";
|
||||
return true;
|
||||
}
|
||||
|
||||
// Accept callback payloads even when `status` is missing.
|
||||
if (status !== "oauth_success" && !accessToken) {
|
||||
if (status === "oauth_error") {
|
||||
error.value = params.get("message") || "Provider sign-in failed.";
|
||||
}
|
||||
if (status !== "oauth_success") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
error.value = "Provider sign-in returned an incomplete session.";
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = readUserFromQuery(params);
|
||||
if (!user) {
|
||||
error.value = "Provider sign-in returned an incomplete session.";
|
||||
return true;
|
||||
}
|
||||
|
||||
authStore.setSession({ access_token: accessToken, user });
|
||||
await router.replace("/");
|
||||
await authStore.ensureInitialized();
|
||||
} catch {
|
||||
error.value = "Unable to restore the provider session.";
|
||||
error.value = "Unable to restore provider session.";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
window.location.replace("/");
|
||||
await router.replace("/");
|
||||
return true;
|
||||
}
|
||||
|
||||
error.value = "Provider sign-in returned an incomplete session.";
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -163,6 +123,8 @@ onMounted(async () => {
|
||||
registrationEnabled.value = !!flags.registration_enabled;
|
||||
providerLoginEnabled.value = !!flags.provider_login_enabled;
|
||||
|
||||
await authStore.ensureInitialized();
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
await router.replace("/");
|
||||
return;
|
||||
|
||||
@@ -4,39 +4,6 @@ import { useSettingsStore } from "../stores/settingsStore";
|
||||
import LoginPage from "../pages/Login.vue";
|
||||
import RegisterPage from "../pages/Register.vue";
|
||||
|
||||
const decodeBase64UrlUTF8 = (value) => {
|
||||
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = normalized.length % 4;
|
||||
const padded = padding === 0 ? normalized : `${normalized}${"=".repeat(4 - padding)}`;
|
||||
const binary = atob(padded);
|
||||
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
};
|
||||
const restoreOAuthSessionFromQuery = (query, authStore) => {
|
||||
// Merge router query with URLSearchParams for full coverage
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const accessToken = query.access_token || query.accessToken || query.token || params.get("access_token") || params.get("accessToken") || params.get("token");
|
||||
|
||||
if (!accessToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const plainUserJSON = query.user_json || params.get("user_json");
|
||||
const encodedUser = query.user || params.get("user");
|
||||
const user = plainUserJSON ? JSON.parse(plainUserJSON) : encodedUser ? JSON.parse(decodeBase64UrlUTF8(encodedUser)) : null;
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
authStore.setSession({ access_token: accessToken, user });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/login",
|
||||
@@ -54,6 +21,12 @@ const routes = [
|
||||
component: () => import("../pages/Home.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/search",
|
||||
name: "Search",
|
||||
component: () => import("../pages/Home.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
name: "Admin",
|
||||
@@ -81,25 +54,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// Only attempt OAuth callback restoration if actual OAuth query params are present
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const hasOAuthParams = to.query.access_token || to.query.accessToken || to.query.token || params.get("access_token") || params.get("accessToken") || params.get("token");
|
||||
|
||||
if (to.path === "/login") {
|
||||
if (hasOAuthParams) {
|
||||
const restored = restoreOAuthSessionFromQuery(to.query, authStore);
|
||||
if (restored) {
|
||||
next({ path: "/", replace: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow login page to be viewed regardless of auth state if no OAuth callback
|
||||
if (!hasOAuthParams) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
await authStore.ensureInitialized();
|
||||
|
||||
if (to.path === "/register") {
|
||||
await settingsStore.loadFeatureFlags();
|
||||
|
||||
@@ -3,23 +3,57 @@ import { useAuthStore } from "../stores/authStore";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8080",
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const authStore = useAuthStore();
|
||||
if (authStore.accessToken) {
|
||||
config.headers.Authorization = `Bearer ${authStore.accessToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
let isRefreshing = false;
|
||||
let refreshSubscribers = [];
|
||||
|
||||
function onRefreshed() {
|
||||
refreshSubscribers.forEach((cb) => cb());
|
||||
refreshSubscribers = [];
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
// Avoid retrying the refresh request itself
|
||||
if (originalRequest.url?.includes("/auth/refresh") || originalRequest.url?.includes("/auth/login")) {
|
||||
const authStore = useAuthStore();
|
||||
authStore.logout();
|
||||
authStore.clearSession();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
// Queue the request until the ongoing refresh completes
|
||||
return new Promise((resolve, reject) => {
|
||||
refreshSubscribers.push(() => {
|
||||
originalRequest._retry = true;
|
||||
apiClient(originalRequest).then(resolve).catch(reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
await apiClient.post("/api/v1/auth/refresh");
|
||||
onRefreshed();
|
||||
return apiClient(originalRequest);
|
||||
} catch {
|
||||
refreshSubscribers = [];
|
||||
const authStore = useAuthStore();
|
||||
authStore.clearSession();
|
||||
return Promise.reject(error);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,10 +3,11 @@ import { ref, computed } from "vue";
|
||||
import apiClient from "../services/apiClient";
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const storedUser = localStorage.getItem("user");
|
||||
const user = ref(storedUser ? JSON.parse(storedUser) : null);
|
||||
const accessToken = ref(localStorage.getItem("accessToken"));
|
||||
const isAuthenticated = computed(() => !!accessToken.value && !!user.value);
|
||||
const user = ref(null);
|
||||
const initialized = ref(false);
|
||||
let initPromise = null;
|
||||
|
||||
const isAuthenticated = computed(() => !!user.value);
|
||||
const isAdmin = computed(() => hasPermission("*") || hasPermission("admin.access"));
|
||||
|
||||
const normalizePermission = (permission) => (permission || "").trim().toLowerCase();
|
||||
@@ -46,10 +47,36 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
};
|
||||
|
||||
const setSession = (responseData) => {
|
||||
accessToken.value = responseData.access_token;
|
||||
user.value = responseData.user;
|
||||
localStorage.setItem("accessToken", accessToken.value);
|
||||
localStorage.setItem("user", JSON.stringify(user.value));
|
||||
user.value = responseData?.user || null;
|
||||
initialized.value = true;
|
||||
};
|
||||
|
||||
const clearSession = () => {
|
||||
user.value = null;
|
||||
initialized.value = true;
|
||||
};
|
||||
|
||||
const loadSession = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/api/v1/auth/me");
|
||||
user.value = response.data?.user || null;
|
||||
} catch {
|
||||
user.value = null;
|
||||
} finally {
|
||||
initialized.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureInitialized = async () => {
|
||||
if (initialized.value) {
|
||||
return;
|
||||
}
|
||||
if (!initPromise) {
|
||||
initPromise = loadSession().finally(() => {
|
||||
initPromise = null;
|
||||
});
|
||||
}
|
||||
await initPromise;
|
||||
};
|
||||
|
||||
const register = async (email, username, password, firstName = "", lastName = "") => {
|
||||
@@ -87,20 +114,20 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
accessToken.value = null;
|
||||
user.value = null;
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("user");
|
||||
apiClient.post("/api/v1/auth/logout").catch(() => {});
|
||||
clearSession();
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken,
|
||||
initialized,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
hasPermission,
|
||||
hasSpacePermission,
|
||||
setSession,
|
||||
clearSession,
|
||||
ensureInitialized,
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
|
||||
@@ -6,6 +6,7 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
const spaces = ref([]);
|
||||
const currentSpace = ref(null);
|
||||
const notes = ref([]);
|
||||
const searchResults = ref([]);
|
||||
const notesSkip = ref(0);
|
||||
const notesLimit = ref(20);
|
||||
const notesHasMore = ref(true);
|
||||
@@ -188,20 +189,30 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
};
|
||||
|
||||
const searchNotes = async (query) => {
|
||||
if (!currentSpace.value?.id) {
|
||||
searchResults.value = [];
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/search`, { params: { q: query } });
|
||||
notes.value = response.data || [];
|
||||
notesHasMore.value = false;
|
||||
notesSkip.value = notes.value.length;
|
||||
searchResults.value = response.data || [];
|
||||
return searchResults.value;
|
||||
} catch (error) {
|
||||
console.error("Error searching notes:", error);
|
||||
searchResults.value = [];
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearchResults = () => {
|
||||
searchResults.value = [];
|
||||
};
|
||||
|
||||
return {
|
||||
spaces,
|
||||
currentSpace,
|
||||
notes,
|
||||
searchResults,
|
||||
notesHasMore,
|
||||
notesLoading,
|
||||
categories,
|
||||
@@ -220,5 +231,6 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
updateNote,
|
||||
deleteNote,
|
||||
searchNotes,
|
||||
clearSearchResults,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
import { marked } from "marked";
|
||||
import { markedHighlight } from "marked-highlight";
|
||||
import hljs from "highlight.js/lib/common";
|
||||
|
||||
marked.use(
|
||||
markedHighlight({
|
||||
langPrefix: "hljs language-",
|
||||
highlight(code, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
}
|
||||
return hljs.highlightAuto(code).value;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Preprocesses markdown content to support extended image size syntax:
|
||||
*
|
||||
@@ -15,15 +31,16 @@
|
||||
*/
|
||||
export function preprocessMarkdown(content) {
|
||||
if (!content) return content;
|
||||
return content.replace(
|
||||
/!\[([^\]]*)\]\(([^\s)"]+)(?:\s+"([^"]*)")?\s+=(\d*%?)[xX](\d*%?)\)/gi,
|
||||
(_, alt, url, title, w, h) => {
|
||||
return content.replace(/!\[([^\]]*)\]\(([^\s)"]+)(?:\s+"([^"]*)")?\s+=(\d*%?)[xX](\d*%?)\)/gi, (_, alt, url, title, w, h) => {
|
||||
const safeAlt = alt.replace(/"/g, """);
|
||||
let attrs = `src="${url}" alt="${safeAlt}"`;
|
||||
if (title) attrs += ` title="${title.replace(/"/g, """)}"`;
|
||||
if (w) attrs += ` width="${w}"`;
|
||||
if (h) attrs += ` height="${h}"`;
|
||||
return `<img ${attrs}>`;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderMarkdown(content) {
|
||||
return marked.parse(preprocessMarkdown(content || ""), { gfm: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user