6 Commits

Author SHA1 Message Date
domrichardson
cf94697d07 feat: Added better md styling
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 58s
2026-03-26 11:41:16 +00:00
domrichardson
94f11be77c fix: Fixed redis user
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m48s
2026-03-26 10:10:07 +00:00
domrichardson
6e642da57a fix: fixes to session storage
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m27s
2026-03-26 10:06:07 +00:00
domrichardson
6774c401bf feat: updated identity providers in admin panel
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 49s
2026-03-25 15:17:48 +00:00
domrichardson
1f1fd90890 feat: Updated admin panel styles 2026-03-25 14:11:39 +00:00
domrichardson
168f5eac83 feat: file explorer
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 50s
2026-03-25 11:27:15 +00:00
36 changed files with 3146 additions and 613 deletions

View File

@@ -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

View File

@@ -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,12 +159,15 @@ func main() {
adminService := services.NewAdminService(
db.UserRepo,
db.GroupRepo,
db.ProviderRepo,
db.LinkRepo,
db.SpaceRepo,
db.MembershipRepo,
db.NoteRepo,
db.CategoryRepo,
db.FeatureFlagRepo,
permissionService,
encryptor,
)
if err := permissionService.EnsureAdminGroup(context.Background()); err != nil {
@@ -140,13 +185,15 @@ 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)
adminHandler := handlers.NewAdminHandler(adminService)
publicHandler := handlers.NewPublicHandler(spaceService, noteService)
settingsHandler := handlers.NewSettingsHandler(authService)
fileService := services.NewFileService(db.FeatureFlagRepo, db.MembershipRepo, encryptor)
fileHandler := handlers.NewFileHandler(fileService)
// Create router
router := mux.NewRouter()
@@ -155,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)
@@ -182,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")
@@ -210,6 +258,14 @@ func main() {
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH")
// File explorer endpoints (space-scoped)
api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.GetFile).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/files/upload", fileHandler.UploadFile).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/files/folder", fileHandler.CreateFolder).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.DeleteFile).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/files/folder", fileHandler.DeleteFolder).Methods("DELETE")
// Admin endpoints
admin := router.PathPrefix("/api/v1/admin").Subrouter()
admin.Use(authMiddleware.Middleware)
@@ -244,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")
@@ -259,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

View File

@@ -3,20 +3,36 @@ 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/aws/protocol/eventstream v1.7.8 // 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
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
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/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

View File

@@ -1,5 +1,37 @@
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 h1:MRNiP6nqa20aEl8fQ6PJpEq11b2d40b16sm4WD7QgMU=
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=
@@ -10,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=
@@ -19,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=

View File

@@ -57,11 +57,32 @@ 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"`
ProviderLoginEnabled bool `json:"provider_login_enabled"`
PublicSharingEnabled bool `json:"public_sharing_enabled"`
FileExplorerEnabled bool `json:"file_explorer_enabled"`
S3Endpoint string `json:"s3_endpoint,omitempty"`
S3Bucket string `json:"s3_bucket,omitempty"`
S3Region string `json:"s3_region,omitempty"`
S3AccessKey string `json:"s3_access_key,omitempty"`
S3SecretKeySet bool `json:"s3_secret_key_set"`
}
// UpdateFeatureFlagsRequest represents admin payload for feature flag updates.
@@ -69,6 +90,12 @@ type UpdateFeatureFlagsRequest struct {
RegistrationEnabled bool `json:"registration_enabled"`
ProviderLoginEnabled bool `json:"provider_login_enabled"`
PublicSharingEnabled bool `json:"public_sharing_enabled"`
FileExplorerEnabled bool `json:"file_explorer_enabled"`
S3Endpoint string `json:"s3_endpoint"`
S3Bucket string `json:"s3_bucket"`
S3Region string `json:"s3_region"`
S3AccessKey string `json:"s3_access_key"`
S3SecretKey string `json:"s3_secret_key"` // empty = keep existing encrypted value
}
// UserDTO represents a user in API responses
@@ -206,6 +233,12 @@ func NewFeatureFlagsDTO(flags *entities.FeatureFlags) *FeatureFlagsDTO {
RegistrationEnabled: flags.RegistrationEnabled,
ProviderLoginEnabled: flags.ProviderLoginEnabled,
PublicSharingEnabled: flags.PublicSharingEnabled,
FileExplorerEnabled: flags.FileExplorerEnabled,
S3Endpoint: flags.S3Endpoint,
S3Bucket: flags.S3Bucket,
S3Region: flags.S3Region,
S3AccessKey: flags.S3AccessKey,
S3SecretKeySet: flags.S3SecretKey != "",
}
}

View File

@@ -10,43 +10,161 @@ import (
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories"
"github.com/noteapp/backend/internal/infrastructure/security"
)
// AdminService handles admin-level operations
type AdminService struct {
userRepo repositories.UserRepository
groupRepo repositories.GroupRepository
providerRepo repositories.AuthProviderRepository
linkRepo repositories.UserProviderLinkRepository
spaceRepo repositories.SpaceRepository
membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository
featureFlagRepo repositories.FeatureFlagRepository
permissionService *PermissionService
encryptor *security.Encryptor
}
// NewAdminService creates a new AdminService
func NewAdminService(
userRepo repositories.UserRepository,
groupRepo repositories.GroupRepository,
providerRepo repositories.AuthProviderRepository,
linkRepo repositories.UserProviderLinkRepository,
spaceRepo repositories.SpaceRepository,
membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository,
featureFlagRepo repositories.FeatureFlagRepository,
permissionService *PermissionService,
encryptor *security.Encryptor,
) *AdminService {
return &AdminService{
userRepo: userRepo,
groupRepo: groupRepo,
providerRepo: providerRepo,
linkRepo: linkRepo,
spaceRepo: spaceRepo,
membershipRepo: membershipRepo,
noteRepo: noteRepo,
categoryRepo: categoryRepo,
featureFlagRepo: featureFlagRepo,
permissionService: permissionService,
encryptor: encryptor,
}
}
// 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)
@@ -299,10 +417,31 @@ func (s *AdminService) UpdateFeatureFlags(ctx context.Context, req *dto.UpdateFe
return nil, errors.New("feature flags are unavailable")
}
// Load existing flags so we can preserve the encrypted S3 secret when not updated
existing, err := s.featureFlagRepo.GetFeatureFlags(ctx)
if err != nil {
existing = entities.NewDefaultFeatureFlags()
}
flags := &entities.FeatureFlags{
RegistrationEnabled: req.RegistrationEnabled,
ProviderLoginEnabled: req.ProviderLoginEnabled,
PublicSharingEnabled: req.PublicSharingEnabled,
FileExplorerEnabled: req.FileExplorerEnabled,
S3Endpoint: strings.TrimSpace(req.S3Endpoint),
S3Bucket: strings.TrimSpace(req.S3Bucket),
S3Region: strings.TrimSpace(req.S3Region),
S3AccessKey: strings.TrimSpace(req.S3AccessKey),
S3SecretKey: existing.S3SecretKey, // keep encrypted secret by default
}
// Only re-encrypt if a new secret was supplied
if s.encryptor != nil && strings.TrimSpace(req.S3SecretKey) != "" {
encrypted, err := s.encryptor.Encrypt(strings.TrimSpace(req.S3SecretKey))
if err != nil {
return nil, err
}
flags.S3SecretKey = encrypted
}
if err := s.featureFlagRepo.UpdateFeatureFlags(ctx, flags); err != nil {

View File

@@ -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 {

View File

@@ -0,0 +1,389 @@
package services
import (
"bytes"
"context"
"errors"
"io"
"path"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/domain/repositories"
"github.com/noteapp/backend/internal/infrastructure/security"
)
// S3Object represents a file or folder entry with key relative to the space root.
type S3Object struct {
Key string `json:"key"`
Size int64 `json:"size"`
LastModified string `json:"last_modified"`
IsFolder bool `json:"is_folder"`
}
// FileService handles S3 file operations scoped to individual spaces.
type FileService struct {
featureFlagRepo repositories.FeatureFlagRepository
membershipRepo repositories.MembershipRepository
encryptor *security.Encryptor
}
// NewFileService creates a new FileService.
func NewFileService(
featureFlagRepo repositories.FeatureFlagRepository,
membershipRepo repositories.MembershipRepository,
encryptor *security.Encryptor,
) *FileService {
return &FileService{
featureFlagRepo: featureFlagRepo,
membershipRepo: membershipRepo,
encryptor: encryptor,
}
}
type s3Config struct {
client *s3.Client
bucket string
}
// buildS3Config loads feature flags, decrypts credentials, and returns an S3 client + bucket name.
func (s *FileService) buildS3Config(ctx context.Context) (*s3Config, error) {
flags, err := s.featureFlagRepo.GetFeatureFlags(ctx)
if err != nil {
return nil, err
}
if !flags.FileExplorerEnabled {
return nil, errors.New("file explorer is disabled")
}
if flags.S3Endpoint == "" || flags.S3Bucket == "" {
return nil, errors.New("S3 is not configured")
}
secretKey := ""
if flags.S3SecretKey != "" && s.encryptor != nil {
secretKey, err = s.encryptor.Decrypt(flags.S3SecretKey)
if err != nil {
return nil, errors.New("failed to decrypt S3 credentials")
}
}
region := flags.S3Region
if region == "" {
region = "us-east-1"
}
cfg := aws.Config{
Region: region,
Credentials: credentials.NewStaticCredentialsProvider(flags.S3AccessKey, secretKey, ""),
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(flags.S3Endpoint)
o.UsePathStyle = true
})
return &s3Config{client: client, bucket: flags.S3Bucket}, nil
}
// validateAccess ensures file explorer is enabled and the user is a member of the space.
// Returns a ready S3 config on success.
func (s *FileService) validateAccess(ctx context.Context, userIDHex, spaceIDHex string) (*s3Config, error) {
cfg, err := s.buildS3Config(ctx)
if err != nil {
return nil, err
}
userID, err := bson.ObjectIDFromHex(userIDHex)
if err != nil {
return nil, errors.New("access denied")
}
spaceID, err := bson.ObjectIDFromHex(spaceIDHex)
if err != nil {
return nil, errors.New("access denied")
}
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return nil, errors.New("access denied")
}
return cfg, nil
}
// spaceBase returns the S3 key prefix for a space: "spaces/<spaceIDHex>/".
func spaceBase(spaceIDHex string) string {
return "spaces/" + spaceIDHex + "/"
}
// resolveRelKey sanitises a relative key and returns the full S3 key,
// rejecting anything that would escape the space prefix.
func resolveRelKey(spaceIDHex, relKey string) (string, error) {
relKey = strings.TrimLeft(strings.TrimSpace(relKey), "/")
cleaned := path.Clean(relKey)
if cleaned == "." || cleaned == "" {
return "", errors.New("key is empty")
}
if strings.Contains(cleaned, "..") {
return "", errors.New("invalid key")
}
base := spaceBase(spaceIDHex)
full := base + cleaned
if !strings.HasPrefix(full, base) {
return "", errors.New("invalid key: outside space boundary")
}
return full, nil
}
// resolveRelPrefix sanitises a relative folder prefix and returns the full S3 prefix.
// An empty relPrefix maps to the space root folder.
func resolveRelPrefix(spaceIDHex, relPrefix string) (string, error) {
base := spaceBase(spaceIDHex)
relPrefix = strings.TrimLeft(strings.TrimSpace(relPrefix), "/")
if relPrefix == "" {
return base, nil
}
cleaned := path.Clean(relPrefix)
if cleaned == "." {
return base, nil
}
if strings.Contains(cleaned, "..") {
return "", errors.New("invalid prefix")
}
full := base + cleaned + "/"
if !strings.HasPrefix(full, base) {
return "", errors.New("invalid prefix: outside space boundary")
}
return full, nil
}
// ListObjects returns objects and virtual folders directly under relPrefix within the space.
// Returned keys are relative to the space root (no "spaces/<spaceId>/" prefix).
func (s *FileService) ListObjects(ctx context.Context, userIDHex, spaceIDHex, relPrefix string) ([]*S3Object, error) {
cfg, err := s.validateAccess(ctx, userIDHex, spaceIDHex)
if err != nil {
return nil, err
}
fullPrefix, err := resolveRelPrefix(spaceIDHex, relPrefix)
if err != nil {
return nil, err
}
base := spaceBase(spaceIDHex)
result, err := cfg.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(cfg.bucket),
Prefix: aws.String(fullPrefix),
Delimiter: aws.String("/"),
})
if err != nil {
return nil, err
}
var objects []*S3Object
for _, cp := range result.CommonPrefixes {
if cp.Prefix != nil {
objects = append(objects, &S3Object{
Key: strings.TrimPrefix(*cp.Prefix, base),
IsFolder: true,
})
}
}
for _, obj := range result.Contents {
if obj.Key == nil || *obj.Key == fullPrefix {
continue
}
// Hide virtual .keep placeholder files used for folder creation
if path.Base(*obj.Key) == ".keep" {
continue
}
size := int64(0)
if obj.Size != nil {
size = *obj.Size
}
lastMod := ""
if obj.LastModified != nil {
lastMod = obj.LastModified.Format(time.RFC3339)
}
objects = append(objects, &S3Object{
Key: strings.TrimPrefix(*obj.Key, base),
Size: size,
LastModified: lastMod,
})
}
return objects, nil
}
// GetObjectContent streams an S3 object, enforcing space boundary.
// relKey is relative to the space root.
func (s *FileService) GetObjectContent(ctx context.Context, userIDHex, spaceIDHex, relKey string) (io.ReadCloser, string, error) {
cfg, err := s.validateAccess(ctx, userIDHex, spaceIDHex)
if err != nil {
return nil, "", err
}
fullKey, err := resolveRelKey(spaceIDHex, relKey)
if err != nil {
return nil, "", err
}
result, err := cfg.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(cfg.bucket),
Key: aws.String(fullKey),
})
if err != nil {
return nil, "", err
}
contentType := "application/octet-stream"
if result.ContentType != nil {
contentType = *result.ContentType
}
return result.Body, contentType, nil
}
// UploadObject stores a file at relKey within the space.
func (s *FileService) UploadObject(ctx context.Context, userIDHex, spaceIDHex, relKey, contentType string, body io.Reader, size int64) error {
cfg, err := s.validateAccess(ctx, userIDHex, spaceIDHex)
if err != nil {
return err
}
fullKey, err := resolveRelKey(spaceIDHex, relKey)
if err != nil {
return err
}
if contentType == "" {
contentType = "application/octet-stream"
}
input := &s3.PutObjectInput{
Bucket: aws.String(cfg.bucket),
Key: aws.String(fullKey),
Body: body,
ContentType: aws.String(contentType),
}
if size > 0 {
input.ContentLength = aws.Int64(size)
}
_, err = cfg.client.PutObject(ctx, input)
return err
}
// CreateFolder creates a virtual folder by uploading a zero-byte .keep placeholder.
func (s *FileService) CreateFolder(ctx context.Context, userIDHex, spaceIDHex, relPath string) error {
cfg, err := s.validateAccess(ctx, userIDHex, spaceIDHex)
if err != nil {
return err
}
base := spaceBase(spaceIDHex)
relPath = strings.Trim(relPath, "/")
cleaned := path.Clean(relPath)
if cleaned == "." || cleaned == "" || strings.Contains(cleaned, "..") {
return errors.New("invalid folder path")
}
fullKey := base + cleaned + "/.keep"
if !strings.HasPrefix(fullKey, base) {
return errors.New("invalid folder path: outside space boundary")
}
zero := int64(0)
_, err = cfg.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(cfg.bucket),
Key: aws.String(fullKey),
Body: bytes.NewReader(nil),
ContentType: aws.String("application/octet-stream"),
ContentLength: aws.Int64(zero),
})
return err
}
// DeleteObject removes a single object within the space.
func (s *FileService) DeleteObject(ctx context.Context, userIDHex, spaceIDHex, relKey string) error {
cfg, err := s.validateAccess(ctx, userIDHex, spaceIDHex)
if err != nil {
return err
}
fullKey, err := resolveRelKey(spaceIDHex, relKey)
if err != nil {
return err
}
_, err = cfg.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(cfg.bucket),
Key: aws.String(fullKey),
})
return err
}
// DeleteFolder recursively deletes all objects under relPrefix within the space.
func (s *FileService) DeleteFolder(ctx context.Context, userIDHex, spaceIDHex, relPrefix string) error {
cfg, err := s.validateAccess(ctx, userIDHex, spaceIDHex)
if err != nil {
return err
}
fullPrefix, err := resolveRelPrefix(spaceIDHex, relPrefix)
if err != nil {
return err
}
// Safety net: refuse to delete the entire space root
if fullPrefix == spaceBase(spaceIDHex) {
return errors.New("cannot delete the space root folder")
}
paginator := s3.NewListObjectsV2Paginator(cfg.client, &s3.ListObjectsV2Input{
Bucket: aws.String(cfg.bucket),
Prefix: aws.String(fullPrefix),
})
var toDelete []types.ObjectIdentifier
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
return err
}
for _, obj := range page.Contents {
if obj.Key != nil {
toDelete = append(toDelete, types.ObjectIdentifier{Key: obj.Key})
}
}
}
if len(toDelete) == 0 {
return nil
}
// Delete in batches of 1000 (S3 limit per DeleteObjects call)
for i := 0; i < len(toDelete); i += 1000 {
end := i + 1000
if end > len(toDelete) {
end = len(toDelete)
}
_, err := cfg.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: aws.String(cfg.bucket),
Delete: &types.Delete{
Objects: toDelete[i:end],
Quiet: aws.Bool(true),
},
})
if err != nil {
return err
}
}
return nil
}

View File

@@ -39,6 +39,12 @@ type FeatureFlags struct {
RegistrationEnabled bool `bson:"registration_enabled"`
ProviderLoginEnabled bool `bson:"provider_login_enabled"`
PublicSharingEnabled bool `bson:"public_sharing_enabled"`
FileExplorerEnabled bool `bson:"file_explorer_enabled"`
S3Endpoint string `bson:"s3_endpoint,omitempty"`
S3Bucket string `bson:"s3_bucket,omitempty"`
S3Region string `bson:"s3_region,omitempty"`
S3AccessKey string `bson:"s3_access_key,omitempty"`
S3SecretKey string `bson:"s3_secret_key,omitempty"` // AES-256-GCM encrypted
}
// NewDefaultFeatureFlags returns safe defaults for a new deployment.
@@ -47,5 +53,6 @@ func NewDefaultFeatureFlags() *FeatureFlags {
RegistrationEnabled: true,
ProviderLoginEnabled: true,
PublicSharingEnabled: true,
FileExplorerEnabled: false,
}
}

View 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
}

View File

@@ -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)
}

View File

@@ -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,
})
}

View File

@@ -0,0 +1,273 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"path"
"strings"
"github.com/gorilla/mux"
"github.com/noteapp/backend/internal/application/services"
"github.com/noteapp/backend/internal/interfaces/middleware"
)
const maxUploadSize = 100 << 20 // 100 MB
// FileHandler exposes S3 file explorer endpoints scoped to spaces.
type FileHandler struct {
fileService *services.FileService
}
// NewFileHandler creates a new FileHandler.
func NewFileHandler(fileService *services.FileService) *FileHandler {
return &FileHandler{fileService: fileService}
}
// extractContext extracts and validates spaceId (URL) and userId (JWT context).
func (h *FileHandler) extractContext(r *http.Request) (spaceID, userID string, err error) {
spaceID = mux.Vars(r)["spaceId"]
if spaceID == "" {
return "", "", fmt.Errorf("missing spaceId")
}
userID, err = middleware.GetUserIDFromContext(r.Context())
return
}
// cleanKey sanitises a user-supplied relative key (strips leading slash, resolves .).
func cleanKey(raw string) string {
k := strings.TrimLeft(strings.TrimSpace(raw), "/")
if c := path.Clean(k); c != "." {
return c
}
return ""
}
// cleanPrefix sanitises a user-supplied relative prefix.
func cleanPrefix(raw string) string {
p := strings.TrimLeft(strings.TrimSpace(raw), "/")
if c := path.Clean(p); c != "." {
return c
}
return ""
}
// respondError maps service errors to appropriate HTTP status codes.
func respondError(w http.ResponseWriter, err error) {
msg := err.Error()
switch {
case strings.Contains(msg, "access denied"), strings.Contains(msg, "disabled"):
http.Error(w, msg, http.StatusForbidden)
default:
http.Error(w, msg, http.StatusBadRequest)
}
}
// ListFiles handles GET /api/v1/spaces/{spaceId}/files/list?prefix=
func (h *FileHandler) ListFiles(w http.ResponseWriter, r *http.Request) {
spaceID, userID, err := h.extractContext(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
relPrefix := cleanPrefix(r.URL.Query().Get("prefix"))
objects, err := h.fileService.ListObjects(r.Context(), userID, spaceID, relPrefix)
if err != nil {
respondError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"objects": objects,
"prefix": relPrefix,
})
}
// GetFile handles GET /api/v1/spaces/{spaceId}/files/object?key=
// Also accepts ?token= as a fallback auth mechanism so markdown images render in-browser.
func (h *FileHandler) GetFile(w http.ResponseWriter, r *http.Request) {
spaceID, userID, err := h.extractContext(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
relKey := cleanKey(r.URL.Query().Get("key"))
if relKey == "" {
http.Error(w, "key is required", http.StatusBadRequest)
return
}
body, contentType, err := h.fileService.GetObjectContent(r.Context(), userID, spaceID, relKey)
if err != nil {
if strings.Contains(err.Error(), "access denied") {
http.Error(w, "access denied", http.StatusForbidden)
return
}
http.Error(w, "file not found", http.StatusNotFound)
return
}
defer body.Close()
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "private, max-age=3600")
io.Copy(w, body) //nolint:errcheck
}
// UploadFile handles POST /api/v1/spaces/{spaceId}/files/upload (multipart/form-data)
// Form fields:
// - path: optional relative folder within the space (e.g. "docs/2024")
// - files: one or more file uploads
func (h *FileHandler) UploadFile(w http.ResponseWriter, r *http.Request) {
spaceID, userID, err := h.extractContext(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
return
}
relFolder := cleanPrefix(r.FormValue("path"))
fileHeaders := r.MultipartForm.File["files"]
if len(fileHeaders) == 0 {
http.Error(w, "no files provided", http.StatusBadRequest)
return
}
var uploaded []string
for _, fh := range fileHeaders {
filename := path.Base(fh.Filename)
if filename == "." || filename == "" {
continue
}
var relKey string
if relFolder != "" {
relKey = relFolder + "/" + filename
} else {
relKey = filename
}
// Detect content-type from header then extension
ct := fh.Header.Get("Content-Type")
if ct == "" || ct == "application/octet-stream" {
if ext := path.Ext(filename); ext != "" {
if t := mime.TypeByExtension(ext); t != "" {
ct = t
}
}
}
if ct == "" {
ct = "application/octet-stream"
}
f, err := fh.Open()
if err != nil {
http.Error(w, "failed to read uploaded file", http.StatusInternalServerError)
return
}
uploadErr := h.fileService.UploadObject(r.Context(), userID, spaceID, relKey, ct, f, fh.Size)
f.Close()
if uploadErr != nil {
respondError(w, uploadErr)
return
}
uploaded = append(uploaded, relKey)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{"uploaded": uploaded})
}
// CreateFolder handles POST /api/v1/spaces/{spaceId}/files/folder
// JSON body: {"path": "new-folder-name"}
func (h *FileHandler) CreateFolder(w http.ResponseWriter, r *http.Request) {
spaceID, userID, err := h.extractContext(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var body struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
relPath := cleanPrefix(body.Path)
if relPath == "" {
http.Error(w, "path is required", http.StatusBadRequest)
return
}
if err := h.fileService.CreateFolder(r.Context(), userID, spaceID, relPath); err != nil {
respondError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"path": relPath})
}
// DeleteFile handles DELETE /api/v1/spaces/{spaceId}/files/object?key=
func (h *FileHandler) DeleteFile(w http.ResponseWriter, r *http.Request) {
spaceID, userID, err := h.extractContext(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
relKey := cleanKey(r.URL.Query().Get("key"))
if relKey == "" {
http.Error(w, "key is required", http.StatusBadRequest)
return
}
if err := h.fileService.DeleteObject(r.Context(), userID, spaceID, relKey); err != nil {
if strings.Contains(err.Error(), "access denied") {
http.Error(w, "access denied", http.StatusForbidden)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// DeleteFolder handles DELETE /api/v1/spaces/{spaceId}/files/folder?prefix=
func (h *FileHandler) DeleteFolder(w http.ResponseWriter, r *http.Request) {
spaceID, userID, err := h.extractContext(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
relPrefix := cleanPrefix(r.URL.Query().Get("prefix"))
if relPrefix == "" {
http.Error(w, "prefix is required", http.StatusBadRequest)
return
}
if err := h.fileService.DeleteFolder(r.Context(), userID, spaceID, relPrefix); err != nil {
if strings.Contains(err.Error(), "access denied") {
http.Error(w, "access denied", http.StatusForbidden)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -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,10 +43,23 @@ func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
return
}
// Extract token from Authorization header
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 == "" {
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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

View File

@@ -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",

View File

@@ -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"

View File

@@ -174,11 +174,12 @@
:note="selectedNote"
:category-options="categoryOptions"
:can-delete="canDeleteNotes"
:space-id="currentSpace?.id"
@save="updateNote"
@delete="deleteNote"
@cancel="cancelEditingNote"
/>
<NoteViewer v-else-if="selectedNote" :note="selectedNote" :category-options="categoryOptions" />
<NoteViewer v-else-if="selectedNote" :note="selectedNote" :category-options="categoryOptions" :space-id="currentSpace?.id" />
<NoteList
v-else
:notes="displayedNotes"
@@ -204,7 +205,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>
@@ -964,6 +965,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;

View File

@@ -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;

View 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&#10;space.project_docs.category.create&#10;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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,331 @@
<template>
<div
class="file-explorer d-flex flex-column border rounded"
style="min-height: 300px"
@dragover.prevent="dragOver = true"
@dragleave="dragOver = false"
@drop.prevent="handleDrop"
:class="{ 'drag-active': dragOver }"
>
<!-- Breadcrumb toolbar -->
<div class="file-explorer-header px-2 py-1 border-bottom bg-light d-flex align-items-center gap-1 flex-wrap">
<i class="mdi mdi-folder-network-outline text-muted me-1" aria-hidden="true"></i>
<button class="btn btn-link btn-sm p-0 text-decoration-none text-dark" @click="navigateTo('')">Space Files</button>
<template v-for="(seg, idx) in breadcrumbs" :key="idx">
<span class="text-muted">/</span>
<button class="btn btn-link btn-sm p-0 text-decoration-none text-dark" @click="navigateTo(seg.prefix)">{{ seg.name }}</button>
</template>
<div class="ms-auto d-flex gap-1">
<button class="btn btn-sm btn-outline-secondary py-0 px-1" title="Upload files" @click="fileInputRef.click()">
<i class="mdi mdi-upload" aria-hidden="true"></i>
</button>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" title="New folder" @click="showNewFolderInput = !showNewFolderInput">
<i class="mdi mdi-folder-plus-outline" aria-hidden="true"></i>
</button>
<button class="btn btn-sm btn-link p-0 text-muted" title="Refresh" @click="loadFiles">
<i class="mdi mdi-refresh" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- New folder input -->
<div v-if="showNewFolderInput" class="px-2 py-1 border-bottom bg-white d-flex gap-1">
<input
ref="newFolderInputRef"
v-model="newFolderName"
type="text"
class="form-control form-control-sm"
placeholder="Folder name"
@keyup.enter="createFolder"
@keyup.esc="showNewFolderInput = false"
/>
<button class="btn btn-sm btn-primary" @click="createFolder">Create</button>
<button class="btn btn-sm btn-secondary" @click="showNewFolderInput = false">Cancel</button>
</div>
<!-- Upload progress -->
<div v-if="uploading" class="px-2 py-1 bg-light border-bottom">
<div class="d-flex align-items-center gap-2">
<div class="progress flex-grow-1" style="height: 6px">
<div class="progress-bar progress-bar-striped progress-bar-animated" :style="{ width: uploadProgress + '%' }"></div>
</div>
<span class="text-muted" style="font-size: 0.7rem">{{ uploadProgress }}%</span>
</div>
</div>
<!-- Error message -->
<div v-if="error" class="alert alert-danger alert-sm m-1 p-1 small mb-0" role="alert">
<i class="mdi mdi-alert-circle-outline me-1" aria-hidden="true"></i>{{ error }}
<button type="button" class="btn-close float-end" style="font-size: 0.6rem" @click="error = ''"></button>
</div>
<!-- Loading / empty -->
<div v-if="loading" class="p-3 text-muted text-center small flex-grow-1"><i class="mdi mdi-loading mdi-spin me-1" aria-hidden="true"></i> Loading...</div>
<div v-else-if="!error && objects.length === 0" class="p-3 text-muted text-center small flex-grow-1">
<i class="mdi mdi-cloud-upload-outline d-block mb-1" style="font-size: 1.5rem" aria-hidden="true"></i>
Drop files here or click Upload
</div>
<!-- File list -->
<div v-else class="file-list flex-grow-1 overflow-auto">
<div
v-for="obj in objects"
:key="obj.key"
class="file-item d-flex align-items-center gap-1 px-2 py-1"
:title="obj.is_folder ? 'Open folder' : 'Insert into note'"
@click="handleClick(obj)"
>
<i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i>
<span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span>
<span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span>
<button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="deleteItem(obj)">
<i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- Hidden file input -->
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" />
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from "vue";
import apiClient from "../services/apiClient";
const props = defineProps({
spaceId: {
type: String,
required: true,
},
modelValue: {
type: String,
default: "",
},
});
const emit = defineEmits(["insert", "update:modelValue"]);
const objects = ref([]);
const loading = ref(false);
const error = ref("");
const currentPrefix = ref(props.modelValue || "");
const dragOver = ref(false);
const uploading = ref(false);
const uploadProgress = ref(0);
const showNewFolderInput = ref(false);
const newFolderName = ref("");
const fileInputRef = ref(null);
const newFolderInputRef = ref(null);
const breadcrumbs = computed(() => {
if (!currentPrefix.value) return [];
const parts = currentPrefix.value.replace(/\/$/, "").split("/").filter(Boolean);
return parts.map((name, i) => ({
name,
prefix: parts.slice(0, i + 1).join("/"),
}));
});
const loadFiles = async () => {
if (!props.spaceId) return;
loading.value = true;
error.value = "";
try {
const res = await apiClient.get(`/api/v1/spaces/${props.spaceId}/files/list`, {
params: { prefix: currentPrefix.value },
});
objects.value = res.data.objects || [];
} catch (e) {
error.value = e.response?.data || "Failed to load files";
} finally {
loading.value = false;
}
};
const navigateTo = (prefix) => {
currentPrefix.value = prefix;
emit("update:modelValue", prefix);
loadFiles();
};
const handleClick = (obj) => {
if (obj.is_folder) {
navigateTo(obj.key.replace(/\/$/, ""));
return;
}
const url = `/api/v1/spaces/${props.spaceId}/files/object?key=${encodeURIComponent(obj.key)}`;
const name = displayName(obj);
const ext = name.split(".").pop().toLowerCase();
const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif"];
const snippet = imageExts.includes(ext) ? `![${name}](${url})` : `[${name}](${url})`;
emit("insert", snippet);
};
const handleFilePick = (e) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) uploadFiles(files);
e.target.value = "";
};
const handleDrop = (e) => {
dragOver.value = false;
const files = Array.from(e.dataTransfer?.files || []);
if (files.length > 0) uploadFiles(files);
};
const uploadFiles = async (files) => {
if (!props.spaceId || files.length === 0) return;
uploading.value = true;
uploadProgress.value = 0;
error.value = "";
const form = new FormData();
form.append("path", currentPrefix.value);
for (const f of files) form.append("files", f);
try {
await apiClient.post(`/api/v1/spaces/${props.spaceId}/files/upload`, form, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (e) => {
uploadProgress.value = e.total ? Math.round((e.loaded * 100) / e.total) : 50;
},
});
await loadFiles();
} catch (e) {
error.value = e.response?.data || "Upload failed";
} finally {
uploading.value = false;
uploadProgress.value = 0;
}
};
const createFolder = async () => {
const name = newFolderName.value.trim();
if (!name || !props.spaceId) return;
const path = currentPrefix.value ? `${currentPrefix.value}/${name}` : name;
error.value = "";
try {
await apiClient.post(`/api/v1/spaces/${props.spaceId}/files/folder`, { path });
newFolderName.value = "";
showNewFolderInput.value = false;
await loadFiles();
} catch (e) {
error.value = e.response?.data || "Failed to create folder";
}
};
const deleteItem = async (obj) => {
const label = displayName(obj);
if (!confirm(`Delete "${label}"?${obj.is_folder ? "\n\nThis will delete all files inside the folder." : ""}`)) return;
error.value = "";
try {
if (obj.is_folder) {
const prefix = obj.key.replace(/\/$/, "");
await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/folder`, { params: { prefix } });
} else {
await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } });
}
await loadFiles();
} catch (e) {
error.value = e.response?.data || "Delete failed";
}
};
const displayName = (obj) => {
const key = obj.is_folder ? obj.key.replace(/\/$/, "") : obj.key;
return key.split("/").pop() || key;
};
const fileIcon = (obj) => {
if (obj.is_folder) return "mdi mdi-folder text-warning";
const ext = displayName(obj).split(".").pop().toLowerCase();
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif"].includes(ext)) return "mdi mdi-file-image text-info";
if (["pdf"].includes(ext)) return "mdi mdi-file-pdf-box text-danger";
if (["doc", "docx", "odt"].includes(ext)) return "mdi mdi-file-word text-primary";
if (["xls", "xlsx", "ods"].includes(ext)) return "mdi mdi-file-excel text-success";
if (["zip", "tar", "gz", "rar", "7z"].includes(ext)) return "mdi mdi-folder-zip text-secondary";
if (["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "mdi mdi-file-video";
if (["mp3", "wav", "ogg", "flac"].includes(ext)) return "mdi mdi-file-music";
if (["js", "ts", "py", "go", "java", "c", "cpp", "rs", "html", "css", "json", "yaml", "yml", "sh"].includes(ext)) return "mdi mdi-file-code text-success";
return "mdi mdi-file-outline text-muted";
};
const formatSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
};
// Load on mount and when spaceId or prefix changes from parent
watch(
() => props.spaceId,
(v) => {
if (v) loadFiles();
},
{ immediate: true },
);
watch(
() => props.modelValue,
(val) => {
if (val !== currentPrefix.value) {
currentPrefix.value = val || "";
loadFiles();
}
},
);
watch(showNewFolderInput, async (v) => {
if (v) {
await nextTick();
newFolderInputRef.value?.focus();
}
});
</script>
<style scoped>
.file-explorer {
background: #fff;
overflow: hidden;
}
.file-explorer-header {
font-size: 0.8rem;
min-height: 36px;
}
.file-list {
max-height: 480px;
}
.file-item {
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s;
color: #333;
line-height: 1.3;
}
.file-item:last-child {
border-bottom: none;
}
.file-item:hover {
background-color: #f0f4ff;
}
.drag-active {
outline: 2px dashed #0d6efd;
outline-offset: -2px;
}
.btn-delete {
opacity: 0;
transition: opacity 0.1s;
}
.file-item:hover .btn-delete {
opacity: 1;
}
</style>

View File

@@ -2,8 +2,17 @@
<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"
class="btn btn-sm ms-2"
:class="showFileExplorer ? 'btn-secondary' : 'btn-outline-secondary'"
:title="showFileExplorer ? 'Hide file explorer' : 'Browse & insert files'"
@click="showFileExplorer = !showFileExplorer"
>
<i class="mdi mdi-folder-open-outline me-1" aria-hidden="true"></i>
Files
</button>
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
</div>
@@ -16,15 +25,19 @@
</div>
<div class="row">
<div class="col-12 col-md-6">
<textarea v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
<div :class="showFileExplorer ? 'col-12 col-md-5' : 'col-12 col-md-6'">
<textarea ref="contentTextareaRef" v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
</div>
<div class="col-12 col-md-6 mt-3 mt-md-0">
<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>
<div v-if="showFileExplorer" class="col-12 col-md-3 mt-3 mt-md-0">
<FileExplorer v-model="fileExplorerPrefix" :space-id="spaceId" @insert="insertAtCursor" />
</div>
</div>
<div class="mt-3">
@@ -68,15 +81,25 @@
</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 } from "vue";
import { marked } from "marked";
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
import DOMPurify from "dompurify";
import { useSettingsStore } from "../stores/settingsStore";
import { renderMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue";
const props = defineProps({
note: {
@@ -91,13 +114,21 @@ const props = defineProps({
type: Boolean,
default: true,
},
spaceId: {
type: String,
default: "",
},
});
const emit = defineEmits(["save", "delete", "cancel"]);
const settingsStore = useSettingsStore();
const publicSharingEnabled = ref(true);
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
const editingNote = ref({ ...props.note });
const contentTextareaRef = ref(null);
const showFileExplorer = ref(false);
const fileExplorerPrefix = ref("");
const tagsInput = ref(props.note.tags?.join(", ") || "");
const passwordAction = ref("keep");
const notePassword = ref("");
@@ -106,7 +137,7 @@ const saveState = ref("saved");
const saveStateTimeout = ref(null);
const renderedMarkdown = computed(() => {
const html = marked.parse(editingNote.value.content || "");
const html = renderMarkdown(editingNote.value.content || "");
return DOMPurify.sanitize(html);
});
@@ -197,6 +228,27 @@ const confirmDelete = () => {
}
};
/** Insert markdown snippet at the textarea cursor position. */
const insertAtCursor = (snippet) => {
const textarea = contentTextareaRef.value;
if (!textarea) {
editingNote.value.content = (editingNote.value.content || "") + snippet;
autoSave();
return;
}
const start = textarea.selectionStart ?? editingNote.value.content?.length ?? 0;
const end = textarea.selectionEnd ?? start;
const before = (editingNote.value.content || "").substring(0, start);
const after = (editingNote.value.content || "").substring(end);
editingNote.value.content = before + snippet + after;
autoSave();
nextTick(() => {
const newPos = start + snippet.length;
textarea.setSelectionRange(newPos, newPos);
textarea.focus();
});
};
onBeforeUnmount(() => {
clearTimeout(saveTimeout.value);
clearTimeout(saveStateTimeout.value);
@@ -237,7 +289,7 @@ onMounted(async () => {
.editor-textarea {
font-family: "Courier New", monospace;
min-height: 400px;
min-height: 600px;
resize: vertical;
}
@@ -276,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>

View File

@@ -30,8 +30,8 @@
<script setup>
import { computed } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { renderMarkdown } from "../utils/markdown.js";
const props = defineProps({
note: {
@@ -42,10 +42,14 @@ const props = defineProps({
type: Array,
default: () => [],
},
spaceId: {
type: String,
default: "",
},
});
const renderedMarkdown = computed(() => {
const html = marked.parse(props.note.content || "");
const html = renderMarkdown(props.note.content || "");
return DOMPurify.sanitize(html);
});

View File

@@ -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);

View File

@@ -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>
@@ -254,6 +203,49 @@
</div>
</div>
<div class="feature-flag-item border rounded p-3">
<div class="d-flex justify-content-between align-items-center mb-0" :class="{ 'mb-3': featureFlagsForm.file_explorer_enabled }">
<div>
<div class="fw-semibold">Enable File Explorer</div>
<div class="small text-muted">Allow users to browse and insert files from an S3 bucket directly into notes.</div>
</div>
<div class="form-check form-switch m-0">
<input id="flag-file-explorer" v-model="featureFlagsForm.file_explorer_enabled" class="form-check-input" type="checkbox" />
</div>
</div>
<div v-if="featureFlagsForm.file_explorer_enabled" class="row g-2 mt-1">
<div class="col-md-6">
<label class="form-label small mb-1">S3 Endpoint URL</label>
<input v-model="featureFlagsForm.s3_endpoint" type="url" class="form-control form-control-sm" placeholder="https://s3.amazonaws.com or custom endpoint" />
</div>
<div class="col-md-6">
<label class="form-label small mb-1">Bucket Name</label>
<input v-model="featureFlagsForm.s3_bucket" type="text" class="form-control form-control-sm" placeholder="my-bucket" />
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Region</label>
<input v-model="featureFlagsForm.s3_region" type="text" class="form-control form-control-sm" placeholder="us-east-1" />
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Access Key</label>
<input v-model="featureFlagsForm.s3_access_key" type="text" class="form-control form-control-sm" autocomplete="off" />
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Secret Key</label>
<input
v-model="featureFlagsForm.s3_secret_key"
type="password"
class="form-control form-control-sm"
:placeholder="featureFlagsForm.s3_secret_key_set ? 'Leave blank to keep current secret' : 'Enter secret key'"
autocomplete="new-password"
/>
<div v-if="featureFlagsForm.s3_secret_key_set && !featureFlagsForm.s3_secret_key" class="small text-success mt-1">
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i> Secret key is set
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-primary" :disabled="savingFeatureFlags" @click="saveFeatureFlags">
{{ savingFeatureFlags ? "Saving..." : "Save Feature Flags" }}
@@ -262,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&#10;space.project_docs.category.create&#10;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>
@@ -316,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);
@@ -331,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);
@@ -344,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);
@@ -364,6 +342,13 @@ const featureFlagsForm = ref({
registration_enabled: true,
provider_login_enabled: true,
public_sharing_enabled: true,
file_explorer_enabled: false,
s3_endpoint: "",
s3_bucket: "",
s3_region: "",
s3_access_key: "",
s3_secret_key: "",
s3_secret_key_set: false,
});
const clearMessages = () => {
@@ -389,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;
@@ -398,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) =>
@@ -429,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 () => {
@@ -462,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") {
@@ -481,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.`;
@@ -490,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();
@@ -524,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 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 openCreateProviderModal = () => {
providerModalMode.value = "create";
selectedProvider.value = null;
showProviderModal.value = 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 () => {
@@ -554,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.";
}
};
@@ -584,6 +645,13 @@ const loadFeatureFlags = async () => {
registration_enabled: !!res.data.registration_enabled,
provider_login_enabled: !!res.data.provider_login_enabled,
public_sharing_enabled: !!res.data.public_sharing_enabled,
file_explorer_enabled: !!res.data.file_explorer_enabled,
s3_endpoint: res.data.s3_endpoint || "",
s3_bucket: res.data.s3_bucket || "",
s3_region: res.data.s3_region || "",
s3_access_key: res.data.s3_access_key || "",
s3_secret_key: "", // never pre-fill the secret
s3_secret_key_set: !!res.data.s3_secret_key_set,
};
} catch (e) {
error.value = e.response?.data || "Failed to load feature flags.";
@@ -596,11 +664,28 @@ const saveFeatureFlags = async () => {
savingFeatureFlags.value = true;
clearMessages();
try {
const res = await apiClient.put("/api/v1/admin/feature-flags", featureFlagsForm.value);
const res = await apiClient.put("/api/v1/admin/feature-flags", {
registration_enabled: featureFlagsForm.value.registration_enabled,
provider_login_enabled: featureFlagsForm.value.provider_login_enabled,
public_sharing_enabled: featureFlagsForm.value.public_sharing_enabled,
file_explorer_enabled: featureFlagsForm.value.file_explorer_enabled,
s3_endpoint: featureFlagsForm.value.s3_endpoint,
s3_bucket: featureFlagsForm.value.s3_bucket,
s3_region: featureFlagsForm.value.s3_region,
s3_access_key: featureFlagsForm.value.s3_access_key,
s3_secret_key: featureFlagsForm.value.s3_secret_key, // blank = keep existing
});
featureFlagsForm.value = {
registration_enabled: !!res.data.registration_enabled,
provider_login_enabled: !!res.data.provider_login_enabled,
public_sharing_enabled: !!res.data.public_sharing_enabled,
file_explorer_enabled: !!res.data.file_explorer_enabled,
s3_endpoint: res.data.s3_endpoint || "",
s3_bucket: res.data.s3_bucket || "",
s3_region: res.data.s3_region || "",
s3_access_key: res.data.s3_access_key || "",
s3_secret_key: "",
s3_secret_key_set: !!res.data.s3_secret_key_set,
};
successMessage.value = "Feature flags updated.";
} catch (e) {
@@ -617,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>

View File

@@ -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;

View File

@@ -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",
@@ -81,25 +48,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();

View File

@@ -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}`;
let isRefreshing = false;
let refreshSubscribers = [];
function onRefreshed() {
refreshSubscribers.forEach((cb) => cb());
refreshSubscribers = [];
}
return config;
});
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);
},
);

View File

@@ -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,

View File

@@ -6,6 +6,7 @@ const DEFAULT_FLAGS = {
registration_enabled: true,
provider_login_enabled: true,
public_sharing_enabled: true,
file_explorer_enabled: false,
};
export const useSettingsStore = defineStore("settings", () => {
@@ -15,6 +16,7 @@ export const useSettingsStore = defineStore("settings", () => {
const registrationEnabled = computed(() => !!featureFlags.value.registration_enabled);
const providerLoginEnabled = computed(() => !!featureFlags.value.provider_login_enabled);
const publicSharingEnabled = computed(() => !!featureFlags.value.public_sharing_enabled);
const fileExplorerEnabled = computed(() => !!featureFlags.value.file_explorer_enabled);
const loadFeatureFlags = async (force = false) => {
if (flagsLoaded.value && !force) {
@@ -42,6 +44,7 @@ export const useSettingsStore = defineStore("settings", () => {
registrationEnabled,
providerLoginEnabled,
publicSharingEnabled,
fileExplorerEnabled,
loadFeatureFlags,
};
});

View File

@@ -0,0 +1,46 @@
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:
*
* ![alt](url =WIDTHxHEIGHT)
* ![alt](url "title" =WIDTHxHEIGHT)
*
* WIDTH and HEIGHT are pixel values or percentages (e.g. 50%).
* Either can be omitted:
* =200x → width 200 only
* =x150 → height 150 only
*
* The syntax is transformed into a plain <img> tag before passing to marked
* because CommonMark terminates the link destination at whitespace, making it
* impossible for marked to see the size spec otherwise.
*/
export function preprocessMarkdown(content) {
if (!content) return content;
return content.replace(/!\[([^\]]*)\]\(([^\s)"]+)(?:\s+"([^"]*)")?\s+=(\d*%?)[xX](\d*%?)\)/gi, (_, alt, url, title, w, h) => {
const safeAlt = alt.replace(/"/g, "&quot;");
let attrs = `src="${url}" alt="${safeAlt}"`;
if (title) attrs += ` title="${title.replace(/"/g, "&quot;")}"`;
if (w) attrs += ` width="${w}"`;
if (h) attrs += ` height="${h}"`;
return `<img ${attrs}>`;
});
}
export function renderMarkdown(content) {
return marked.parse(preprocessMarkdown(content || ""), { gfm: true });
}