5 Commits

Author SHA1 Message Date
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
domrichardson
b253bec9fc feat: changes to the note editor
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 31s
2026-03-25 09:55:02 +00:00
domrichardson
3bfdf24fff feat: more workflow fixes
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 59s
2026-03-24 16:25:53 +00:00
24 changed files with 2579 additions and 383 deletions

View File

@@ -24,10 +24,10 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set image tags - name: Set image tags
id: vars id: vars
shell: bash shell: sh
run: | run: |
SHORT_SHA="${GITHUB_SHA::7}" SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
BRANCH="${GITHUB_REF_NAME//\//-}" BRANCH=$(printf '%s' "$GITHUB_REF_NAME" | tr '/' '-')
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"

View File

@@ -117,12 +117,15 @@ func main() {
adminService := services.NewAdminService( adminService := services.NewAdminService(
db.UserRepo, db.UserRepo,
db.GroupRepo, db.GroupRepo,
db.ProviderRepo,
db.LinkRepo,
db.SpaceRepo, db.SpaceRepo,
db.MembershipRepo, db.MembershipRepo,
db.NoteRepo, db.NoteRepo,
db.CategoryRepo, db.CategoryRepo,
db.FeatureFlagRepo, db.FeatureFlagRepo,
permissionService, permissionService,
encryptor,
) )
if err := permissionService.EnsureAdminGroup(context.Background()); err != nil { if err := permissionService.EnsureAdminGroup(context.Background()); err != nil {
@@ -147,6 +150,8 @@ func main() {
adminHandler := handlers.NewAdminHandler(adminService) adminHandler := handlers.NewAdminHandler(adminService)
publicHandler := handlers.NewPublicHandler(spaceService, noteService) publicHandler := handlers.NewPublicHandler(spaceService, noteService)
settingsHandler := handlers.NewSettingsHandler(authService) settingsHandler := handlers.NewSettingsHandler(authService)
fileService := services.NewFileService(db.FeatureFlagRepo, db.MembershipRepo, encryptor)
fileHandler := handlers.NewFileHandler(fileService)
// Create router // Create router
router := mux.NewRouter() router := mux.NewRouter()
@@ -210,6 +215,14 @@ func main() {
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE") api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH") 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 endpoints
admin := router.PathPrefix("/api/v1/admin").Subrouter() admin := router.PathPrefix("/api/v1/admin").Subrouter()
admin.Use(authMiddleware.Middleware) admin.Use(authMiddleware.Middleware)
@@ -244,10 +257,12 @@ func main() {
}) })
}) })
admin.HandleFunc("/users", adminHandler.ListUsers).Methods("GET") 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("/users/{userId}/groups", adminHandler.UpdateUserGroups).Methods("PUT")
admin.HandleFunc("/groups", adminHandler.ListGroups).Methods("GET") admin.HandleFunc("/groups", adminHandler.ListGroups).Methods("GET")
admin.HandleFunc("/groups", adminHandler.CreateGroup).Methods("POST") admin.HandleFunc("/groups", adminHandler.CreateGroup).Methods("POST")
admin.HandleFunc("/groups/{groupId}", adminHandler.UpdateGroup).Methods("PUT") 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", adminHandler.ListAllSpaces).Methods("GET")
admin.HandleFunc("/spaces/{spaceId}", adminHandler.UpdateSpace).Methods("PUT") admin.HandleFunc("/spaces/{spaceId}", adminHandler.UpdateSpace).Methods("PUT")
admin.HandleFunc("/spaces/{spaceId}", adminHandler.DeleteSpace).Methods("DELETE") admin.HandleFunc("/spaces/{spaceId}", adminHandler.DeleteSpace).Methods("DELETE")
@@ -259,6 +274,8 @@ func main() {
admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT") admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT")
// manage identity providers — admin-only // manage identity providers — admin-only
admin.HandleFunc("/auth/providers", authHandler.CreateProvider).Methods("POST") 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 // Serve static files (frontend) for all other routes
// This must be after all API route handlers to allow API routes to take precedence // This must be after all API route handlers to allow API routes to take precedence

View File

@@ -12,6 +12,18 @@ require (
) )
require ( require (
github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect
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/aws-sdk-go-v2/service/s3 v1.97.2 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/klauspost/compress v1.17.6 // indirect github.com/klauspost/compress v1.17.6 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect

View File

@@ -1,3 +1,27 @@
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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=

View File

@@ -57,11 +57,32 @@ type CreateAuthProviderRequest struct {
IsActive bool `json:"is_active"` 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. // FeatureFlagsDTO represents app-wide feature flags in API responses.
type FeatureFlagsDTO struct { type FeatureFlagsDTO struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
ProviderLoginEnabled bool `json:"provider_login_enabled"` ProviderLoginEnabled bool `json:"provider_login_enabled"`
PublicSharingEnabled bool `json:"public_sharing_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. // UpdateFeatureFlagsRequest represents admin payload for feature flag updates.
@@ -69,6 +90,12 @@ type UpdateFeatureFlagsRequest struct {
RegistrationEnabled bool `json:"registration_enabled"` RegistrationEnabled bool `json:"registration_enabled"`
ProviderLoginEnabled bool `json:"provider_login_enabled"` ProviderLoginEnabled bool `json:"provider_login_enabled"`
PublicSharingEnabled bool `json:"public_sharing_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 // UserDTO represents a user in API responses
@@ -206,6 +233,12 @@ func NewFeatureFlagsDTO(flags *entities.FeatureFlags) *FeatureFlagsDTO {
RegistrationEnabled: flags.RegistrationEnabled, RegistrationEnabled: flags.RegistrationEnabled,
ProviderLoginEnabled: flags.ProviderLoginEnabled, ProviderLoginEnabled: flags.ProviderLoginEnabled,
PublicSharingEnabled: flags.PublicSharingEnabled, 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/application/dto"
"github.com/noteapp/backend/internal/domain/entities" "github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories" "github.com/noteapp/backend/internal/domain/repositories"
"github.com/noteapp/backend/internal/infrastructure/security"
) )
// AdminService handles admin-level operations // AdminService handles admin-level operations
type AdminService struct { type AdminService struct {
userRepo repositories.UserRepository userRepo repositories.UserRepository
groupRepo repositories.GroupRepository groupRepo repositories.GroupRepository
providerRepo repositories.AuthProviderRepository
linkRepo repositories.UserProviderLinkRepository
spaceRepo repositories.SpaceRepository spaceRepo repositories.SpaceRepository
membershipRepo repositories.MembershipRepository membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository categoryRepo repositories.CategoryRepository
featureFlagRepo repositories.FeatureFlagRepository featureFlagRepo repositories.FeatureFlagRepository
permissionService *PermissionService permissionService *PermissionService
encryptor *security.Encryptor
} }
// NewAdminService creates a new AdminService // NewAdminService creates a new AdminService
func NewAdminService( func NewAdminService(
userRepo repositories.UserRepository, userRepo repositories.UserRepository,
groupRepo repositories.GroupRepository, groupRepo repositories.GroupRepository,
providerRepo repositories.AuthProviderRepository,
linkRepo repositories.UserProviderLinkRepository,
spaceRepo repositories.SpaceRepository, spaceRepo repositories.SpaceRepository,
membershipRepo repositories.MembershipRepository, membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository, noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository, categoryRepo repositories.CategoryRepository,
featureFlagRepo repositories.FeatureFlagRepository, featureFlagRepo repositories.FeatureFlagRepository,
permissionService *PermissionService, permissionService *PermissionService,
encryptor *security.Encryptor,
) *AdminService { ) *AdminService {
return &AdminService{ return &AdminService{
userRepo: userRepo, userRepo: userRepo,
groupRepo: groupRepo, groupRepo: groupRepo,
providerRepo: providerRepo,
linkRepo: linkRepo,
spaceRepo: spaceRepo, spaceRepo: spaceRepo,
membershipRepo: membershipRepo, membershipRepo: membershipRepo,
noteRepo: noteRepo, noteRepo: noteRepo,
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
featureFlagRepo: featureFlagRepo, featureFlagRepo: featureFlagRepo,
permissionService: permissionService, 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 // ListUsers returns all users as admin DTOs
func (s *AdminService) ListUsers(ctx context.Context) ([]*dto.AdminUserDTO, error) { func (s *AdminService) ListUsers(ctx context.Context) ([]*dto.AdminUserDTO, error) {
users, err := s.userRepo.ListAllUsers(ctx) 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") 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{ flags := &entities.FeatureFlags{
RegistrationEnabled: req.RegistrationEnabled, RegistrationEnabled: req.RegistrationEnabled,
ProviderLoginEnabled: req.ProviderLoginEnabled, ProviderLoginEnabled: req.ProviderLoginEnabled,
PublicSharingEnabled: req.PublicSharingEnabled, 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 { if err := s.featureFlagRepo.UpdateFeatureFlags(ctx, flags); err != nil {

View File

@@ -319,6 +319,57 @@ func (s *AuthService) CreateProvider(ctx context.Context, req *dto.CreateAuthPro
return dto.NewAuthProviderDTO(provider), nil 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. // BuildProviderAuthorizationURL constructs a provider authorization URL.
func (s *AuthService) BuildProviderAuthorizationURL(ctx context.Context, providerID bson.ObjectID, redirectURI, state string) (string, error) { func (s *AuthService) BuildProviderAuthorizationURL(ctx context.Context, providerID bson.ObjectID, redirectURI, state string) (string, error) {
flags, err := s.GetFeatureFlags(ctx) flags, err := s.GetFeatureFlags(ctx)

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"` RegistrationEnabled bool `bson:"registration_enabled"`
ProviderLoginEnabled bool `bson:"provider_login_enabled"` ProviderLoginEnabled bool `bson:"provider_login_enabled"`
PublicSharingEnabled bool `bson:"public_sharing_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. // NewDefaultFeatureFlags returns safe defaults for a new deployment.
@@ -47,5 +53,6 @@ func NewDefaultFeatureFlags() *FeatureFlags {
RegistrationEnabled: true, RegistrationEnabled: true,
ProviderLoginEnabled: true, ProviderLoginEnabled: true,
PublicSharingEnabled: true, PublicSharingEnabled: true,
FileExplorerEnabled: false,
} }
} }

View File

@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/noteapp/backend/internal/interfaces/middleware"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto" "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}) 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 // UpdateUserGroups handles PUT /admin/users/{userId}/groups
func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request) {
userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"]) 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) 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 // ListGroups handles GET /admin/groups
func (h *AdminHandler) ListGroups(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) ListGroups(w http.ResponseWriter, r *http.Request) {
groups, err := h.adminService.ListGroups(r.Context()) 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(flags) 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

@@ -141,6 +141,30 @@ func (h *AuthHandler) CreateProvider(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(provider) 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. // StartProviderLogin redirects the browser to the selected provider.
func (h *AuthHandler) StartProviderLogin(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) StartProviderLogin(w http.ResponseWriter, r *http.Request) {
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"]) providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])

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

@@ -41,8 +41,14 @@ func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
return return
} }
// Extract token from Authorization header // Extract token from Authorization header.
// For GET /files/object, also accept ?token= so markdown images render in-browser.
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if authHeader == "" && r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/files/object") {
if tok := r.URL.Query().Get("token"); tok != "" {
authHeader = "Bearer " + tok
}
}
if authHeader == "" { if authHeader == "" {
http.Error(w, "Missing authorization header", http.StatusUnauthorized) http.Error(w, "Missing authorization header", http.StatusUnauthorized)
return return

View File

@@ -174,11 +174,12 @@
:note="selectedNote" :note="selectedNote"
:category-options="categoryOptions" :category-options="categoryOptions"
:can-delete="canDeleteNotes" :can-delete="canDeleteNotes"
:space-id="currentSpace?.id"
@save="updateNote" @save="updateNote"
@delete="deleteNote" @delete="deleteNote"
@cancel="cancelEditingNote" @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 <NoteList
v-else v-else
:notes="displayedNotes" :notes="displayedNotes"
@@ -204,7 +205,7 @@
</div> </div>
</div> </div>
<div v-else-if="currentUser && isAdminRoute" class="container py-4"> <div v-else-if="currentUser && isAdminRoute" class="admin-route-view">
<router-view /> <router-view />
</div> </div>
@@ -342,10 +343,11 @@ const canManageSpaceSettings = computed(
authStore.hasSpacePermission(currentSpace.value, "settings.member.view"), authStore.hasSpacePermission(currentSpace.value, "settings.member.view"),
); );
const flattenCategories = (items, level = 0) => const flattenCategories = (items, trail = []) =>
items.flatMap((category) => { items.flatMap((category) => {
const label = `${" ".repeat(level)}${category.name}`; const nextTrail = [...trail, category.name];
return [{ id: category.id, name: category.name, label }, ...(category.subcategories?.length ? flattenCategories(category.subcategories, level + 1) : [])]; const label = nextTrail.join("/");
return [{ id: category.id, name: category.name, label }, ...(category.subcategories?.length ? flattenCategories(category.subcategories, nextTrail) : [])];
}); });
const categoryOptions = computed(() => flattenCategories(categoryTree.value)); const categoryOptions = computed(() => flattenCategories(categoryTree.value));
@@ -963,6 +965,16 @@ const logout = () => {
display: block; 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) { @media (max-width: 768px) {
.app-navbar { .app-navbar {
display: grid; display: grid;

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> <template>
<teleport to="body"> <teleport to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')"> <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" role="document"> <div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Edit Space</h5> <h5 class="modal-title">Edit Space</h5>
@@ -96,7 +96,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal-backdrop fade show"></div> <div class="modal-backdrop fade show admin-modal-backdrop"></div>
</teleport> </teleport>
</template> </template>
@@ -252,3 +252,30 @@ const deleteSpace = async () => {
} }
}; };
</script> </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

@@ -4,8 +4,15 @@
<button class="btn btn-sm btn-primary" @click="saveNote">Save</button> <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 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 class="btn btn-sm btn-outline-secondary ms-2" @click="emit('cancel')">Cancel</button>
<button class="btn btn-sm btn-secondary ms-2" @click="togglePreview"> <button
{{ showPreview ? "Edit" : "Preview" }} 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> </button>
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span> <span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
</div> </div>
@@ -19,15 +26,19 @@
</div> </div>
<div class="row"> <div class="row">
<div :class="{ 'col-md-6': showPreview, 'col-12': !showPreview }"> <div :class="showFileExplorer ? 'col-12 col-md-5' : 'col-12 col-md-6'">
<textarea v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea> <textarea ref="contentTextareaRef" v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
</div> </div>
<div v-if="showPreview" class="col-md-6"> <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 class="preview-pane border rounded p-3">
<div v-html="renderedMarkdown"></div> <div v-html="renderedMarkdown"></div>
</div> </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>
<div class="mt-3"> <div class="mt-3">
@@ -76,10 +87,13 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onBeforeUnmount, onMounted } from "vue"; import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
import { marked } from "marked"; import { marked } from "marked";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useSettingsStore } from "../stores/settingsStore"; import { useSettingsStore } from "../stores/settingsStore";
import { useAuthStore } from "../stores/authStore";
import { preprocessMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue";
const props = defineProps({ const props = defineProps({
note: { note: {
@@ -94,14 +108,22 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
spaceId: {
type: String,
default: "",
},
}); });
const emit = defineEmits(["save", "delete", "cancel"]); const emit = defineEmits(["save", "delete", "cancel"]);
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const authStore = useAuthStore();
const publicSharingEnabled = ref(true); const publicSharingEnabled = ref(true);
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
const editingNote = ref({ ...props.note }); const editingNote = ref({ ...props.note });
const showPreview = ref(false); const contentTextareaRef = ref(null);
const showFileExplorer = ref(false);
const fileExplorerPrefix = ref("");
const tagsInput = ref(props.note.tags?.join(", ") || ""); const tagsInput = ref(props.note.tags?.join(", ") || "");
const passwordAction = ref("keep"); const passwordAction = ref("keep");
const notePassword = ref(""); const notePassword = ref("");
@@ -110,8 +132,18 @@ const saveState = ref("saved");
const saveStateTimeout = ref(null); const saveStateTimeout = ref(null);
const renderedMarkdown = computed(() => { const renderedMarkdown = computed(() => {
const html = marked.parse(editingNote.value.content || ""); const html = marked.parse(preprocessMarkdown(editingNote.value.content || ""));
return DOMPurify.sanitize(html); let clean = DOMPurify.sanitize(html);
// Inject access token into space file API URLs so images render without a separate JS fetch
const token = authStore.accessToken;
if (token && props.spaceId) {
clean = clean.replace(/((?:src|href)=["'])([^"']*\/api\/v1\/spaces\/[^"']*\/files\/object[^"']*)(["'])/g, (_, attr, url, quote) => {
if (url.includes("token=")) return attr + url + quote;
const sep = url.includes("?") ? "&" : "?";
return `${attr}${url}${sep}token=${encodeURIComponent(token)}${quote}`;
});
}
return clean;
}); });
const saveStatusLabel = computed(() => { const saveStatusLabel = computed(() => {
@@ -201,8 +233,25 @@ const confirmDelete = () => {
} }
}; };
const togglePreview = () => { /** Insert markdown snippet at the textarea cursor position. */
showPreview.value = !showPreview.value; 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(() => { onBeforeUnmount(() => {

View File

@@ -32,6 +32,8 @@
import { computed } from "vue"; import { computed } from "vue";
import { marked } from "marked"; import { marked } from "marked";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useAuthStore } from "../stores/authStore";
import { preprocessMarkdown } from "../utils/markdown.js";
const props = defineProps({ const props = defineProps({
note: { note: {
@@ -42,11 +44,26 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
spaceId: {
type: String,
default: "",
},
}); });
const authStore = useAuthStore();
const renderedMarkdown = computed(() => { const renderedMarkdown = computed(() => {
const html = marked.parse(props.note.content || ""); const html = marked.parse(preprocessMarkdown(props.note.content || ""));
return DOMPurify.sanitize(html); let clean = DOMPurify.sanitize(html);
const token = authStore.accessToken;
if (token && props.spaceId) {
clean = clean.replace(/((?:src|href)=["'])([^"']*\/api\/v1\/spaces\/[^"']*\/files\/object[^"']*)(["'])/g, (_, attr, url, quote) => {
if (url.includes("token=")) return attr + url + quote;
const sep = url.includes("?") ? "&" : "?";
return `${attr}${url}${sep}token=${encodeURIComponent(token)}${quote}`;
});
}
return clean;
}); });
const categoryLabel = computed(() => { const categoryLabel = computed(() => {

View File

@@ -1,34 +1,40 @@
<template> <template>
<div class="admin-page"> <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> <div>
<h2 class="mb-1">Admin Panel</h2> <h2 class="mb-1">Admin Panel</h2>
<p class="text-muted mb-0">Manage users, groups, spaces, and identity providers.</p> <p class="text-muted mb-0">Manage users, groups, spaces, and identity providers.</p>
</div> </div>
</div>
<button class="btn btn-outline-secondary" @click="router.push('/')">Back to Notes</button> <button class="btn btn-outline-secondary" @click="router.push('/')">Back to Notes</button>
</div> </div>
<div v-if="error" class="alert alert-danger">{{ error }}</div> <div v-if="error" class="alert alert-danger">{{ error }}</div>
<div v-if="successMessage" class="alert alert-success">{{ successMessage }}</div> <div v-if="successMessage" class="alert alert-success">{{ successMessage }}</div>
<ul class="nav nav-tabs mb-3"> <div class="admin-shell">
<li class="nav-item"> <div v-if="showMobileSidebar" class="admin-sidebar-backdrop" @click="showMobileSidebar = false"></div>
<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>
<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"> <section v-if="activeTab === 'users'" class="admin-section card border-0 shadow-sm">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2"> <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-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-if="users.length === 0" class="border rounded p-3 text-muted">No users found.</div>
<div v-else class="table-responsive"> <div v-else class="list-group users-list">
<table class="table table-sm table-hover align-middle mb-0"> <div v-for="u in users" :key="u.id" class="list-group-item user-row">
<thead class="table-light"> <div class="user-row-main">
<tr> <div class="user-name-line">
<th>Username</th> <span class="fw-semibold user-name">{{ u.username }}</span>
<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>
<span class="badge" :class="u.is_active ? 'text-bg-success' : 'text-bg-secondary'"> <span class="badge" :class="u.is_active ? 'text-bg-success' : 'text-bg-secondary'">
{{ u.is_active ? "Active" : "Inactive" }} {{ u.is_active ? "Active" : "Inactive" }}
</span> </span>
</td> </div>
<td class="text-muted small">{{ formatDate(u.created_at) }}</td>
</tr> <div class="user-meta-grid">
</tbody> <div class="user-meta-item">
</table> <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>
</div> </div>
</section> </section>
@@ -106,7 +103,10 @@
<div class="small text-muted">{{ group.description || "No description" }}</div> <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 class="small text-muted">{{ (group.permissions || []).length }} permission{{ (group.permissions || []).length === 1 ? "" : "s" }}</div>
</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-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> </div>
</div> </div>
@@ -140,77 +140,26 @@
<section v-if="activeTab === 'providers'" class="admin-section card border-0 shadow-sm"> <section v-if="activeTab === 'providers'" class="admin-section card border-0 shadow-sm">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Configured Providers</h5> <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-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>
<div v-if="loadingProviders" class="text-muted small">Loading providers...</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-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 v-for="provider in providers" :key="provider.id" class="list-group-item d-flex justify-content-between align-items-center">
<div> <div class="d-flex align-items-center gap-2">
<div class="fw-semibold">{{ provider.name }}</div> <span class="fw-semibold">{{ provider.name }}</span>
<div class="small text-muted">{{ provider.type.toUpperCase() }} · {{ provider.scopes.join(", ") }}</div>
<div class="small text-muted">Callback: {{ buildCallbackUrl(provider.id) }}</div>
</div> </div>
<span class="badge" :class="provider.is_active ? 'text-bg-success' : 'text-bg-secondary'"> <div class="d-flex gap-2">
{{ provider.is_active ? "Active" : "Disabled" }} <button class="btn btn-sm btn-outline-secondary" @click="openEditProviderModal(provider)">Edit</button>
</span>
</div> </div>
</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>
<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> </div>
</section> </section>
@@ -254,6 +203,49 @@
</div> </div>
</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"> <div class="d-flex justify-content-end">
<button class="btn btn-primary" :disabled="savingFeatureFlags" @click="saveFeatureFlags"> <button class="btn btn-primary" :disabled="savingFeatureFlags" @click="saveFeatureFlags">
{{ savingFeatureFlags ? "Saving..." : "Save Feature Flags" }} {{ savingFeatureFlags ? "Saving..." : "Save Feature Flags" }}
@@ -262,53 +254,32 @@
</div> </div>
</div> </div>
</section> </section>
</main>
</div>
</div> </div>
<AdminSpaceModal v-if="showSpaceModal && selectedSpace" :space="selectedSpace" :users="users" @close="showSpaceModal = false" @saved="onSpaceSaved" @deleted="onSpaceDeleted" /> <AdminSpaceModal v-if="showSpaceModal && selectedSpace" :space="selectedSpace" :users="users" @close="showSpaceModal = false" @saved="onSpaceSaved" @deleted="onSpaceDeleted" />
<teleport to="body"> <AdminGroupModal
<div v-if="showGroupModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeGroupModal"> v-if="showGroupModal"
<div class="modal-dialog modal-lg modal-dialog-centered" role="document"> :mode="groupModalMode"
<div class="modal-content"> :group="selectedGroup"
<div class="modal-header"> :is-system-group="isEditingSystemGroup"
<h5 class="modal-title">{{ groupModalMode === "create" ? "Create Group" : "Edit Group" }}</h5> :submitting="submittingGroupModal"
<button type="button" class="btn-close" aria-label="Close" @click="closeGroupModal"></button> @close="closeGroupModal"
</div> @submit="submitGroupModal"
<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>
<div class="mb-3"> <AdminUserModal v-if="showUserModal && selectedUser" :user="selectedUser" :groups="groups" :submitting="submittingUserModal" @close="closeUserModal" @submit="submitUserModal" />
<label class="form-label">Description</label>
<input v-model="groupModalForm.description" class="form-control" type="text" :disabled="isEditingSystemGroup" />
</div>
<div> <AdminProviderModal
<label class="form-label">Permissions (one per line)</label> v-if="showProviderModal"
<textarea :mode="providerModalMode"
v-model="groupModalForm.permissionsText" :provider="selectedProvider"
class="form-control permissions-textarea" :submitting="submittingProviderModal"
rows="10" @close="closeProviderModal"
placeholder="space.create&#10;space.project_docs.category.create&#10;space.project_docs.*" @submit="submitProviderModal"
: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>
</template> </template>
<script setup> <script setup>
@@ -316,14 +287,34 @@ import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import AdminSpaceModal from "../components/AdminSpaceModal.vue"; 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 router = useRouter();
const activeTab = ref("users"); const activeTab = ref("users");
const showMobileSidebar = ref(false);
const error = ref(""); const error = ref("");
const successMessage = 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 users = ref([]);
const loadingUsers = ref(false); const loadingUsers = ref(false);
const showUserModal = ref(false);
const submittingUserModal = ref(false);
const selectedUser = ref(null);
const groups = ref([]); const groups = ref([]);
const loadingGroups = ref(false); const loadingGroups = ref(false);
@@ -331,11 +322,7 @@ const showGroupModal = ref(false);
const groupModalMode = ref("create"); const groupModalMode = ref("create");
const editingGroupId = ref(""); const editingGroupId = ref("");
const submittingGroupModal = ref(false); const submittingGroupModal = ref(false);
const groupModalForm = ref({ const selectedGroup = ref(null);
name: "",
description: "",
permissionsText: "",
});
const spaces = ref([]); const spaces = ref([]);
const loadingSpaces = ref(false); const loadingSpaces = ref(false);
@@ -344,19 +331,10 @@ const selectedSpace = ref(null);
const providers = ref([]); const providers = ref([]);
const loadingProviders = ref(false); const loadingProviders = ref(false);
const submittingProvider = ref(false); const showProviderModal = ref(false);
const providerForm = ref({ const providerModalMode = ref("create");
name: "", const selectedProvider = ref(null);
type: "oidc", const submittingProviderModal = ref(false);
client_id: "",
client_secret: "",
authorization_url: "",
token_url: "",
userinfo_url: "",
id_token_claim: "id_token",
scopes: "openid, profile, email",
is_active: true,
});
const loadingFeatureFlags = ref(false); const loadingFeatureFlags = ref(false);
const savingFeatureFlags = ref(false); const savingFeatureFlags = ref(false);
@@ -364,6 +342,13 @@ const featureFlagsForm = ref({
registration_enabled: true, registration_enabled: true,
provider_login_enabled: true, provider_login_enabled: true,
public_sharing_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 = () => { const clearMessages = () => {
@@ -389,8 +374,10 @@ const loadUsers = async () => {
} }
}; };
const updateUserGroups = async (userId, groupIds) => { const updateUserGroups = async (userId, groupIds, options = {}) => {
if (!options.silent) {
clearMessages(); clearMessages();
}
try { try {
const response = await apiClient.put(`/api/v1/admin/users/${userId}/groups`, { group_ids: groupIds }); const response = await apiClient.put(`/api/v1/admin/users/${userId}/groups`, { group_ids: groupIds });
const updatedUser = response.data; const updatedUser = response.data;
@@ -398,26 +385,70 @@ const updateUserGroups = async (userId, groupIds) => {
if (userIndex !== -1) { if (userIndex !== -1) {
users.value[userIndex] = { ...users.value[userIndex], ...updatedUser }; users.value[userIndex] = { ...users.value[userIndex], ...updatedUser };
} }
if (!options.silent) {
successMessage.value = "User groups updated."; successMessage.value = "User groups updated.";
}
return updatedUser;
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to update user groups."; error.value = e.response?.data || "Failed to update user groups.";
throw e;
} }
}; };
const resetGroupModalForm = () => { const getUserGroupSummary = (user) => {
groupModalForm.value = { const ids = user?.group_ids || [];
name: "", if (!ids.length) {
description: "", return "No groups";
permissionsText: "", }
}; 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(() => { const isEditingSystemGroup = computed(() => {
if (groupModalMode.value !== "edit") { if (groupModalMode.value !== "edit") {
return false; return false;
} }
const group = groups.value.find((item) => item.id === editingGroupId.value); return !!selectedGroup.value?.is_system;
return !!group?.is_system;
}); });
const splitPermissionsByNewline = (raw) => const splitPermissionsByNewline = (raw) =>
@@ -429,24 +460,21 @@ const splitPermissionsByNewline = (raw) =>
const openCreateGroupModal = () => { const openCreateGroupModal = () => {
groupModalMode.value = "create"; groupModalMode.value = "create";
editingGroupId.value = ""; editingGroupId.value = "";
resetGroupModalForm(); selectedGroup.value = null;
showGroupModal.value = true; showGroupModal.value = true;
}; };
const openEditGroupModal = (group) => { const openEditGroupModal = (group) => {
groupModalMode.value = "edit"; groupModalMode.value = "edit";
editingGroupId.value = group.id; editingGroupId.value = group.id;
groupModalForm.value = { selectedGroup.value = { ...group };
name: group.name || "",
description: group.description || "",
permissionsText: (group.permissions || []).join("\n"),
};
showGroupModal.value = true; showGroupModal.value = true;
}; };
const closeGroupModal = () => { const closeGroupModal = () => {
showGroupModal.value = false; showGroupModal.value = false;
submittingGroupModal.value = false; submittingGroupModal.value = false;
selectedGroup.value = null;
}; };
const loadGroups = async () => { const loadGroups = async () => {
@@ -462,14 +490,14 @@ const loadGroups = async () => {
} }
}; };
const submitGroupModal = async () => { const submitGroupModal = async (formData) => {
submittingGroupModal.value = true; submittingGroupModal.value = true;
clearMessages(); clearMessages();
try { try {
const payload = { const payload = {
name: groupModalForm.value.name, name: formData.name,
description: groupModalForm.value.description, description: formData.description,
permissions: splitPermissionsByNewline(groupModalForm.value.permissionsText), permissions: splitPermissionsByNewline(formData.permissionsText),
}; };
if (groupModalMode.value === "create") { if (groupModalMode.value === "create") {
@@ -481,7 +509,6 @@ const submitGroupModal = async () => {
} }
closeGroupModal(); closeGroupModal();
resetGroupModalForm();
await Promise.all([loadGroups(), loadUsers()]); await Promise.all([loadGroups(), loadUsers()]);
} catch (e) { } catch (e) {
error.value = e.response?.data || `Failed to ${groupModalMode.value === "create" ? "create" : "update"} group.`; 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 () => { const loadSpaces = async () => {
loadingSpaces.value = true; loadingSpaces.value = true;
clearMessages(); clearMessages();
@@ -524,21 +569,43 @@ const onSpaceDeleted = (deletedSpace) => {
successMessage.value = `Space "${deletedSpace.name}" deleted.`; successMessage.value = `Space "${deletedSpace.name}" deleted.`;
}; };
const buildCallbackUrl = (providerId) => `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/callback`; const openCreateProviderModal = () => {
providerModalMode.value = "create";
selectedProvider.value = null;
showProviderModal.value = true;
};
const resetProviderForm = () => { const openEditProviderModal = (provider) => {
providerForm.value = { providerModalMode.value = "edit";
name: "", selectedProvider.value = { ...provider };
type: "oidc", showProviderModal.value = true;
client_id: "", };
client_secret: "",
authorization_url: "", const closeProviderModal = () => {
token_url: "", showProviderModal.value = false;
userinfo_url: "", submittingProviderModal.value = false;
id_token_claim: "id_token", selectedProvider.value = null;
scopes: "openid, profile, email", };
is_active: true,
}; 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 () => { const loadProviders = async () => {
@@ -554,24 +621,18 @@ const loadProviders = async () => {
} }
}; };
const createProvider = async () => { const deleteProvider = async (provider) => {
submittingProvider.value = true; if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) {
return;
}
clearMessages(); clearMessages();
try { try {
await apiClient.post("/api/v1/admin/auth/providers", { await apiClient.delete(`/api/v1/admin/auth/providers/${provider.id}`);
...providerForm.value, providers.value = providers.value.filter((item) => item.id !== provider.id);
scopes: providerForm.value.scopes successMessage.value = `Provider "${provider.name}" deleted.`;
.split(",")
.map((scope) => scope.trim())
.filter(Boolean),
});
successMessage.value = "Provider added.";
resetProviderForm();
await loadProviders();
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to create provider."; error.value = e.response?.data || "Failed to delete provider.";
} finally {
submittingProvider.value = false;
} }
}; };
@@ -584,6 +645,13 @@ const loadFeatureFlags = async () => {
registration_enabled: !!res.data.registration_enabled, registration_enabled: !!res.data.registration_enabled,
provider_login_enabled: !!res.data.provider_login_enabled, provider_login_enabled: !!res.data.provider_login_enabled,
public_sharing_enabled: !!res.data.public_sharing_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) { } catch (e) {
error.value = e.response?.data || "Failed to load feature flags."; error.value = e.response?.data || "Failed to load feature flags.";
@@ -596,11 +664,28 @@ const saveFeatureFlags = async () => {
savingFeatureFlags.value = true; savingFeatureFlags.value = true;
clearMessages(); clearMessages();
try { 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 = { featureFlagsForm.value = {
registration_enabled: !!res.data.registration_enabled, registration_enabled: !!res.data.registration_enabled,
provider_login_enabled: !!res.data.provider_login_enabled, provider_login_enabled: !!res.data.provider_login_enabled,
public_sharing_enabled: !!res.data.public_sharing_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."; successMessage.value = "Feature flags updated.";
} catch (e) { } catch (e) {
@@ -617,15 +702,203 @@ onMounted(async () => {
<style scoped> <style scoped>
.admin-page { .admin-page {
max-width: 1100px; width: 100%;
margin: 0 auto; 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 { .admin-section {
border-radius: 12px; border-radius: 12px;
} }
.permissions-textarea { .users-list .list-group-item {
font-family: "Courier New", monospace; 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> </style>

View File

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

View File

@@ -0,0 +1,26 @@
/**
* 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}>`;
});
}