first commit
This commit is contained in:
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Docker Compose Environment Configuration (Example)
|
||||||
|
# Copy this file to .env and update values as needed
|
||||||
|
|
||||||
|
# MongoDB Configuration
|
||||||
|
MONGODB_URI=mongodb://admin:password@mongodb:27017/noteapp?authSource=admin
|
||||||
|
|
||||||
|
# Backend Configuration
|
||||||
|
BACKEND_PORT=8080
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters-change-in-production
|
||||||
|
ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882
|
||||||
|
FRONTEND_URL="http://localhost"
|
||||||
|
|
||||||
|
VITE_API_BASE_URL="http://localhost"
|
||||||
|
|
||||||
|
# Default Admin
|
||||||
|
DEFAULT_ADMIN_EMAIL=admin@notely.local
|
||||||
|
DEFAULT_ADMIN_USERNAME=admin
|
||||||
|
DEFAULT_ADMIN_PASSWORD=ChangeThisAdminPassword123!
|
||||||
|
|
||||||
|
# Nginx Configuration
|
||||||
|
NGINX_HTTP_PORT=80
|
||||||
|
NGINX_HTTPS_PORT=443
|
||||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.docker/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Secrets (never commit!)
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
secret*
|
||||||
|
|
||||||
|
# Go
|
||||||
|
*.o
|
||||||
|
*.a
|
||||||
|
*.so
|
||||||
|
.go/
|
||||||
98
ENV_SETUP.md
Normal file
98
ENV_SETUP.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Environment Configuration
|
||||||
|
|
||||||
|
Copy `.env.example` files and configure for your environment:
|
||||||
|
|
||||||
|
## Backend (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# MongoDB
|
||||||
|
MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp?authSource=admin
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters
|
||||||
|
JWT_ISSUER=noteapp
|
||||||
|
|
||||||
|
# Encryption (32 bytes = 32 characters)
|
||||||
|
ENCRYPTION_KEY=00000000000000000000000000000000
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=8080
|
||||||
|
ENV=development
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# CORS (comma-separated for multiple origins)
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_REQUESTS=50
|
||||||
|
RATE_LIMIT_WINDOW=1s
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=http://localhost:8080
|
||||||
|
VITE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development vs Production
|
||||||
|
|
||||||
|
### Development (.env.development)
|
||||||
|
|
||||||
|
- Less strict security (for easier testing)
|
||||||
|
- Localhost CORS allowed
|
||||||
|
- JWT secrets can be simple
|
||||||
|
- Logging more verbose
|
||||||
|
|
||||||
|
### Production (.env.production)
|
||||||
|
|
||||||
|
- Strict security requirements
|
||||||
|
- Specific CORS origins only
|
||||||
|
- Strong random JWT secrets
|
||||||
|
- Limited logging (performance)
|
||||||
|
- All environment variables must be set
|
||||||
|
|
||||||
|
## Generating Secrets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# JWT Secret (32+ characters)
|
||||||
|
openssl rand -base64 32
|
||||||
|
|
||||||
|
# Encryption Key (32 bytes)
|
||||||
|
openssl rand -hex 16 # outputs 32 characters
|
||||||
|
|
||||||
|
# Random token
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
Environment variables are defined in `docker-compose.yml` and will override `.env` files. Update the file for your deployment:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
MONGODB_URI: mongodb://admin:password@mongodb:27017/noteapp?authSource=admin
|
||||||
|
JWT_SECRET: your-secret-key-change-in-production
|
||||||
|
# ... other vars
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kubernetes
|
||||||
|
|
||||||
|
Use `kubectl create secret` for sensitive data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create secret from literal values
|
||||||
|
kubectl create secret generic app-secrets \
|
||||||
|
--from-literal=mongodb-uri="..." \
|
||||||
|
--from-literal=jwt-secret="..." \
|
||||||
|
-n noteapp
|
||||||
|
|
||||||
|
# Or use ConfigMap for non-sensitive config
|
||||||
|
kubectl create configmap app-config \
|
||||||
|
--from-file=config.yaml \
|
||||||
|
-n noteapp
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**IMPORTANT**: Never commit .env files or secrets to version control!
|
||||||
69
PERMISSIONS.md
Normal file
69
PERMISSIONS.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Permissions Reference
|
||||||
|
|
||||||
|
This file lists the permissions currently checked by the application.
|
||||||
|
|
||||||
|
## Global Permissions
|
||||||
|
|
||||||
|
- `*`
|
||||||
|
- Full access wildcard
|
||||||
|
- Also used by the built-in Admin group
|
||||||
|
- admin.access
|
||||||
|
- Access to admin API and admin UI
|
||||||
|
- space.create
|
||||||
|
- Create a new space
|
||||||
|
- space.edit
|
||||||
|
- Global space edit capability (used as fallback alongside space-scoped settings edit)
|
||||||
|
- space.delete
|
||||||
|
- Global space delete capability (used as fallback alongside space-scoped delete)
|
||||||
|
|
||||||
|
## Space-Scoped Permission Format
|
||||||
|
|
||||||
|
space.<space_permission_key>.<action>
|
||||||
|
|
||||||
|
- space_permission_key is derived from the space name (normalized token)
|
||||||
|
- Example:
|
||||||
|
- space.product_docs.note.create
|
||||||
|
- space.product_docs.settings.member.manage
|
||||||
|
|
||||||
|
## Space-Scoped Actions Currently Enforced
|
||||||
|
|
||||||
|
### Space Management
|
||||||
|
|
||||||
|
- settings.edit
|
||||||
|
- delete
|
||||||
|
|
||||||
|
### Member Management
|
||||||
|
|
||||||
|
- settings.member.manage
|
||||||
|
- settings.member.view
|
||||||
|
|
||||||
|
### Category Management
|
||||||
|
|
||||||
|
- category.create
|
||||||
|
- category.edit
|
||||||
|
- category.delete
|
||||||
|
|
||||||
|
### Note Management
|
||||||
|
|
||||||
|
- note.create
|
||||||
|
- note.edit
|
||||||
|
- note.delete
|
||||||
|
|
||||||
|
## Wildcard Support
|
||||||
|
|
||||||
|
Permissions support wildcard matching with \*.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- space.product_docs.\*
|
||||||
|
- Grants all permissions for the product_docs space
|
||||||
|
- space.\*.note.create
|
||||||
|
- Grants note.create for all spaces
|
||||||
|
- `*`
|
||||||
|
- Grants all permissions globally
|
||||||
|
|
||||||
|
## Built-in Group
|
||||||
|
|
||||||
|
- Admin group is auto-created at startup if missing
|
||||||
|
- Admin group permissions:
|
||||||
|
- `*`
|
||||||
304
QUICKSTART.md
Normal file
304
QUICKSTART.md
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
# 🚀 Quick Start Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose (recommended for quickest setup)
|
||||||
|
- OR: Go 1.21+, Node.js 18+, MongoDB 7.0+
|
||||||
|
|
||||||
|
## Option 1: Docker Compose (Recommended - 1 Command)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone/navigate to project
|
||||||
|
cd noteapp
|
||||||
|
|
||||||
|
# Start everything
|
||||||
|
docker-compose up
|
||||||
|
|
||||||
|
# Wait for services to initialize (~30 seconds)
|
||||||
|
# Then open: http://localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
**Services running**:
|
||||||
|
|
||||||
|
- Notely: http://localhost:8080
|
||||||
|
- MongoDB: localhost:27017
|
||||||
|
- Nginx Reverse Proxy: http://localhost:80
|
||||||
|
|
||||||
|
**Test user (after startup)**:
|
||||||
|
|
||||||
|
- Register a new account at http://localhost/register
|
||||||
|
- Login and create a Space
|
||||||
|
- Add Categories and Notes
|
||||||
|
|
||||||
|
## Option 2: Local Development
|
||||||
|
|
||||||
|
### Backend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Copy environment file
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
# Ensure MongoDB is running
|
||||||
|
# Docker: docker run -d -p 27017:27017 -e MONGO_INITDB_ROOT_USERNAME=admin \
|
||||||
|
# -e MONGO_INITDB_ROOT_PASSWORD=password mongo:7.0-alpine
|
||||||
|
|
||||||
|
# Run backend
|
||||||
|
go run ./cmd/server/main.go
|
||||||
|
|
||||||
|
# Logs should show: "Server starting on port 8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Copy environment file
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start dev server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Open: http://localhost:5173 in browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### Backend Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run with verbose output
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
# Run specific test
|
||||||
|
go test -v -run TestRegisterUser ./tests/unit/...
|
||||||
|
|
||||||
|
# With coverage
|
||||||
|
go test -cover ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Watch mode
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Key API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Register
|
||||||
|
curl -X POST http://localhost:8080/api/v1/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"username": "myuser",
|
||||||
|
"password": "SecurePassword123",
|
||||||
|
"password_confirm": "SecurePassword123",
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Login
|
||||||
|
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "SecurePassword123"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Response contains: access_token, refresh_token, user data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Space
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/v1/spaces \
|
||||||
|
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "My First Space",
|
||||||
|
"description": "Notes for my project",
|
||||||
|
"icon": "📚",
|
||||||
|
"is_public": false
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Note
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/v1/spaces/{spaceId}/notes \
|
||||||
|
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"title": "My First Note",
|
||||||
|
"content": "# Markdown Heading\n\nThis is a note",
|
||||||
|
"tags": ["important", "work"],
|
||||||
|
"category_id": null,
|
||||||
|
"is_pinned": false,
|
||||||
|
"is_favorite": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Notes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8080/api/v1/spaces/{spaceId}/notes/search?q=important" \
|
||||||
|
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Troubleshooting
|
||||||
|
|
||||||
|
### MongoDB Connection Error
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: Failed to connect to database
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
docker run -d -p 27017:27017 \
|
||||||
|
-e MONGO_INITDB_ROOT_USERNAME=admin \
|
||||||
|
-e MONGO_INITDB_ROOT_PASSWORD=password \
|
||||||
|
mongo:7.0-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find process on port 8080
|
||||||
|
lsof -i :8080
|
||||||
|
|
||||||
|
# Kill it
|
||||||
|
kill -9 <PID>
|
||||||
|
|
||||||
|
# Or use different port
|
||||||
|
PORT=8081 go run ./cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS Errors
|
||||||
|
|
||||||
|
Make sure frontend and backend URLs match in:
|
||||||
|
|
||||||
|
- Frontend: `VITE_API_BASE_URL` in `.env`
|
||||||
|
- Backend: `CORS_ALLOWED_ORIGINS` in `.env`
|
||||||
|
|
||||||
|
### MongoDB Auth Failed
|
||||||
|
|
||||||
|
If using Docker Compose:
|
||||||
|
|
||||||
|
- Username: `admin`
|
||||||
|
- Password: `password`
|
||||||
|
- Connection string includes `?authSource=admin`
|
||||||
|
|
||||||
|
## 📚 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
noteapp/
|
||||||
|
├── backend/ # Go REST API
|
||||||
|
│ ├── cmd/server/ # Entry point
|
||||||
|
│ ├── internal/
|
||||||
|
│ │ ├── domain/ # Business logic
|
||||||
|
│ │ ├── application/ # Services & DTOs
|
||||||
|
│ │ ├── infrastructure/ # DB, auth, security
|
||||||
|
│ │ └── interfaces/ # HTTP handlers
|
||||||
|
│ ├── tests/ # Test files
|
||||||
|
│ ├── go.mod & go.sum # Dependencies
|
||||||
|
│ └── README.md
|
||||||
|
│
|
||||||
|
├── frontend/ # Vue 3 SPA
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # UI components
|
||||||
|
│ │ ├── pages/ # Page components
|
||||||
|
│ │ ├── stores/ # Pinia state
|
||||||
|
│ │ ├── services/ # API client
|
||||||
|
│ │ ├── router/ # Vue Router
|
||||||
|
│ │ ├── assets/ # Styles & images
|
||||||
|
│ │ └── main.js # Entry point
|
||||||
|
│ ├── tests/ # Test files
|
||||||
|
│ ├── package.json # Dependencies
|
||||||
|
│ └── vite.config.js # Vite configuration
|
||||||
|
│
|
||||||
|
├── devops/
|
||||||
|
│ ├── docker/
|
||||||
|
│ │ ├── Dockerfile.backend
|
||||||
|
│ │ ├── Dockerfile.frontend
|
||||||
|
│ │ └── nginx.conf
|
||||||
|
│ └── kubernetes/
|
||||||
|
│ └── deployment.yaml
|
||||||
|
│
|
||||||
|
├── docker-compose.yml # Local development setup
|
||||||
|
├── README.md # Project docs
|
||||||
|
├── ARCHITECTURE.md # Architecture overview
|
||||||
|
├── SECURITY.md # Security implementation
|
||||||
|
└── ENV_SETUP.md # Environment configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎓 Learning Resources
|
||||||
|
|
||||||
|
### Understanding the Code
|
||||||
|
|
||||||
|
1. **Start here**: `ARCHITECTURE.md` - Clean architecture pattern
|
||||||
|
2. **Then read**: Backend `domain/entities/*.go` - Core models
|
||||||
|
3. **Next**: Backend `application/services/*.go` - Business logic
|
||||||
|
4. **UI**: Frontend `src/stores/authStore.js` - State management
|
||||||
|
5. **API**: Backend `interfaces/handlers/*.go` - HTTP layer
|
||||||
|
|
||||||
|
### Security Deep Dive
|
||||||
|
|
||||||
|
See `SECURITY.md` for:
|
||||||
|
|
||||||
|
- Password hashing (Argon2id)
|
||||||
|
- JWT authentication
|
||||||
|
- Authorization (RBAC)
|
||||||
|
- Input validation
|
||||||
|
- XSS prevention
|
||||||
|
- CSRF protection
|
||||||
|
|
||||||
|
## 🚀 Next Steps
|
||||||
|
|
||||||
|
1. **Explore the UI**: Create spaces, notes, categories
|
||||||
|
2. **Read the code**: Start with `index ARCHITECTURE.md`
|
||||||
|
3. **Run tests**: `go test ./...` and `npm test`
|
||||||
|
4. **Deploy**: Use `docker-compose.yml` or Kubernetes
|
||||||
|
5. **Extend**: Add OAuth2, WebSockets, more features
|
||||||
|
|
||||||
|
## 💡 Quick Tips
|
||||||
|
|
||||||
|
- **Hot reload**: Changes auto-reload in dev mode
|
||||||
|
- **Network tab**: Check API calls in browser DevTools
|
||||||
|
- **Logs**: Docker: `docker-compose logs -f service-name`
|
||||||
|
- **Database GUI**: MongoDB Compass (free tool to browse data)
|
||||||
|
- **API testing**: Postman or `curl` commands
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
- Check logs: `docker-compose logs`
|
||||||
|
- Review `SECURITY.md` for auth issues
|
||||||
|
- Check `ENV_SETUP.md` for config problems
|
||||||
|
- See `ARCHITECTURE.md` for code structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Now you're ready to explore and extend Notely! 🎉**
|
||||||
426
README.md
Normal file
426
README.md
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
# Notely - Secure Multi-Space Note-Taking Application
|
||||||
|
|
||||||
|
A production-ready, secure multi-tenant note-taking platform built with Go, Vue 3, and MongoDB.
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Go 1.21+ (for local development)
|
||||||
|
- Node.js 18+ (for frontend development)
|
||||||
|
- MongoDB 7.0+ (for local development)
|
||||||
|
|
||||||
|
### Development with Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services
|
||||||
|
docker-compose up
|
||||||
|
|
||||||
|
# Backend: http://localhost:8080
|
||||||
|
# Frontend: http://localhost:5173
|
||||||
|
# MongoDB: localhost:27017
|
||||||
|
# Nginx: http://localhost:80
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development Setup
|
||||||
|
|
||||||
|
#### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
export MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp?authSource=admin
|
||||||
|
export JWT_SECRET=your-secret-key
|
||||||
|
export ENCRYPTION_KEY=00000000000000000000000000000000
|
||||||
|
|
||||||
|
# Run migrations and server
|
||||||
|
go run ./cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Architecture
|
||||||
|
|
||||||
|
### Backend (GoClean Architecture)
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── cmd/server/ # Entry point
|
||||||
|
├── internal/
|
||||||
|
│ ├── domain/ # Business logic (entities, interfaces)
|
||||||
|
│ ├── application/ # Use cases (services, DTOs)
|
||||||
|
│ ├── infrastructure/ # External dependencies (DB, auth)
|
||||||
|
│ └── interfaces/ # API handlers & middleware
|
||||||
|
├── pkg/ # Public packages
|
||||||
|
└── tests/ # Test suites
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (Vue 3 Composition API)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # Reusable Vue components
|
||||||
|
│ ├── pages/ # Page components
|
||||||
|
│ ├── stores/ # Pinia state management
|
||||||
|
│ ├── services/ # API client
|
||||||
|
│ ├── router/ # Vue Router config
|
||||||
|
│ ├── assets/ # Styles and assets
|
||||||
|
│ └── main.js # Entry point
|
||||||
|
├── index.html
|
||||||
|
└── vite.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Security Features
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- **Argon2id password hashing** - Industry-standard PBKDF2
|
||||||
|
- **JWT tokens** with short expiration (1 hour)
|
||||||
|
- **HTTP-only secure cookies** for refresh tokens
|
||||||
|
- **CSRF protection** via SameSite cookies
|
||||||
|
- **Brute-force protection** via login attempt tracking
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
- **Role-based access control (RBAC)** per space:
|
||||||
|
- Owner: Full control
|
||||||
|
- Editor: Edit notes and categories
|
||||||
|
- Viewer: Read-only access
|
||||||
|
- **Space-level data isolation** - all queries include space_id
|
||||||
|
- **IDOR prevention** - middleware enforces ownership verification
|
||||||
|
|
||||||
|
### Data Security
|
||||||
|
|
||||||
|
- **Encryption at rest** for sensitive fields (OAuth secrets)
|
||||||
|
- **HTTPS/TLS** in production (Nginx reverse proxy)
|
||||||
|
- **Content Security Policy (CSP)** headers
|
||||||
|
- **XSS protection** - DOMPurify for markdown sanitization
|
||||||
|
- **SQL injection prevention** - parameterized queries (MongoDB)
|
||||||
|
|
||||||
|
### API Security
|
||||||
|
|
||||||
|
- **Rate limiting** - IP-based and user-based
|
||||||
|
- **Security headers** - HSTS, X-Frame-Options, X-Content-Type-Options
|
||||||
|
- **CORS properly configured** - whitelist origin domains
|
||||||
|
- **Input validation** on all endpoints
|
||||||
|
|
||||||
|
## 📦 API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/register - Register new user
|
||||||
|
POST /api/v1/auth/login - Login user
|
||||||
|
POST /api/v1/auth/refresh - Refresh access token
|
||||||
|
POST /api/v1/auth/logout - Logout user
|
||||||
|
GET /health - Health check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spaces
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/spaces - List user's spaces
|
||||||
|
POST /api/v1/spaces - Create space
|
||||||
|
GET /api/v1/spaces/{spaceId} - Get space details
|
||||||
|
PUT /api/v1/spaces/{spaceId} - Update space
|
||||||
|
DELETE /api/v1/spaces/{spaceId} - Delete space
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/spaces/{spaceId}/notes - List notes
|
||||||
|
POST /api/v1/spaces/{spaceId}/notes - Create note
|
||||||
|
GET /api/v1/spaces/{spaceId}/notes/{noteId} - Get note
|
||||||
|
PUT /api/v1/spaces/{spaceId}/notes/{noteId} - Update note
|
||||||
|
DELETE /api/v1/spaces/{spaceId}/notes/{noteId} - Delete note
|
||||||
|
GET /api/v1/spaces/{spaceId}/notes/search?q= - Search notes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/spaces/{spaceId}/categories - List categories
|
||||||
|
POST /api/v1/spaces/{spaceId}/categories - Create category
|
||||||
|
PUT /api/v1/spaces/{spaceId}/categories/{id} - Update category
|
||||||
|
DELETE /api/v1/spaces/{spaceId}/categories/{id} - Delete category
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🗄️ Database Design
|
||||||
|
|
||||||
|
### MongoDB Collections
|
||||||
|
|
||||||
|
#### users
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
email: String (unique),
|
||||||
|
username: String (unique),
|
||||||
|
password_hash: String,
|
||||||
|
first_name: String,
|
||||||
|
last_name: String,
|
||||||
|
avatar: String,
|
||||||
|
is_active: Boolean,
|
||||||
|
email_verified: Boolean,
|
||||||
|
created_at: Date,
|
||||||
|
updated_at: Date,
|
||||||
|
last_login_at: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### spaces
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
icon: String,
|
||||||
|
owner_id: ObjectId,
|
||||||
|
is_public: Boolean,
|
||||||
|
created_at: Date,
|
||||||
|
updated_at: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### memberships
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
user_id: ObjectId,
|
||||||
|
space_id: ObjectId,
|
||||||
|
role: String (owner|editor|viewer),
|
||||||
|
joined_at: Date,
|
||||||
|
invited_by: ObjectId,
|
||||||
|
invited_at: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### notes
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
space_id: ObjectId,
|
||||||
|
category_id: ObjectId,
|
||||||
|
title: String,
|
||||||
|
content: String (Markdown),
|
||||||
|
tags: [String],
|
||||||
|
is_pinned: Boolean,
|
||||||
|
is_favorite: Boolean,
|
||||||
|
created_by: ObjectId,
|
||||||
|
updated_by: ObjectId,
|
||||||
|
created_at: Date,
|
||||||
|
updated_at: Date,
|
||||||
|
viewed_at: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### categories
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
space_id: ObjectId,
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
parent_id: ObjectId (for hierarchical structure),
|
||||||
|
icon: String,
|
||||||
|
order: Number,
|
||||||
|
created_by: ObjectId,
|
||||||
|
updated_by: ObjectId,
|
||||||
|
created_at: Date,
|
||||||
|
updated_at: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Indexes
|
||||||
|
|
||||||
|
```
|
||||||
|
users: { email: 1 (unique), username: 1 (unique) }
|
||||||
|
spaces: { owner_id: 1, created_at: -1 }
|
||||||
|
memberships: { user_id: 1, space_id: 1 (unique), space_id: 1 }
|
||||||
|
notes: { space_id: 1, category_id: 1, updated_at: -1, text: "text" }
|
||||||
|
categories: { space_id: 1, parent_id: 1, order: 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Deployment
|
||||||
|
|
||||||
|
### Docker Compose (Development/Testing)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Services:
|
||||||
|
|
||||||
|
- **MongoDB** (port 27017)
|
||||||
|
- **Backend API** (port 8080)
|
||||||
|
- **Frontend** (port 5173)
|
||||||
|
- **Nginx Reverse Proxy** (port 80)
|
||||||
|
|
||||||
|
### Kubernetes (Production)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create namespace and secrets
|
||||||
|
kubectl apply -f devops/kubernetes/deployment.yaml
|
||||||
|
|
||||||
|
# Verify deployment
|
||||||
|
kubectl get pods -n noteapp
|
||||||
|
kubectl port-forward svc/frontend 5173:5173 -n noteapp
|
||||||
|
kubectl port-forward svc/backend 8080:8080 -n noteapp
|
||||||
|
```
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
- **StatefulSet** for MongoDB with persistent storage
|
||||||
|
- **Deployments** for backend and frontend with horizontal scaling
|
||||||
|
- **Ingress** for routing (requires ingress controller)
|
||||||
|
- **HPA** (Horizontal Pod Autoscaler) for automatic scaling
|
||||||
|
- **Liveness & readiness probes** for health checks
|
||||||
|
- **Resource limits** for fair resource allocation
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### Backend Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go test ./...
|
||||||
|
go test -v ./tests/unit/...
|
||||||
|
go test -v ./tests/integration/...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run test
|
||||||
|
npm run test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
#### Backend (.env)
|
||||||
|
|
||||||
|
```
|
||||||
|
MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp
|
||||||
|
JWT_SECRET=your-secret-key-min-32-chars
|
||||||
|
ENCRYPTION_KEY=32-char-encryption-key-for-secrets
|
||||||
|
PORT=8080
|
||||||
|
LOG_LEVEL=info
|
||||||
|
ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend (.env)
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_API_BASE_URL=http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Development Guidelines
|
||||||
|
|
||||||
|
### Code Structure
|
||||||
|
|
||||||
|
- Follow clean architecture principles
|
||||||
|
- Separate concerns: domain, application, infrastructure
|
||||||
|
- Use interfaces for dependency injection
|
||||||
|
- Keep services testable and focused
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
|
||||||
|
1. **Never store secrets in code** - use environment variables
|
||||||
|
2. **Validate all inputs** on backend
|
||||||
|
3. **Sanitize outputs** before rendering
|
||||||
|
4. **Use HTTPS in production**
|
||||||
|
5. **Implement rate limiting** on APIs
|
||||||
|
6. **Log security events** (login attempts, permission denied)
|
||||||
|
7. **Audit trail** for sensitive operations
|
||||||
|
|
||||||
|
### Commit Message Format
|
||||||
|
|
||||||
|
```
|
||||||
|
[TYPE] Description
|
||||||
|
|
||||||
|
types: feat, fix, docs, style, refactor, test, chore
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 API Documentation
|
||||||
|
|
||||||
|
### Request/Response Format
|
||||||
|
|
||||||
|
All API requests and responses use JSON.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: Create Note
|
||||||
|
curl -X POST http://localhost:8080/api/v1/spaces/{spaceId}/notes \
|
||||||
|
-H "Authorization: Bearer {accessToken}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"title": "My Note",
|
||||||
|
"content": "# Markdown content",
|
||||||
|
"tags": ["tag1", "tag2"],
|
||||||
|
"category_id": null,
|
||||||
|
"is_pinned": false,
|
||||||
|
"is_favorite": false
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Error Handling
|
||||||
|
|
||||||
|
All errors return appropriate HTTP status codes:
|
||||||
|
|
||||||
|
- `400` - Bad Request
|
||||||
|
- `401` - Unauthorized
|
||||||
|
- `403` - Forbidden (insufficient permissions)
|
||||||
|
- `404` - Not Found
|
||||||
|
- `409` - Conflict (e.g., duplicate email)
|
||||||
|
- `429` - Too Many Requests (rate limit exceeded)
|
||||||
|
- `500` - Internal Server Error
|
||||||
|
|
||||||
|
## 🎯 Future Enhancements
|
||||||
|
|
||||||
|
- [ ] OAuth2/OIDC integration
|
||||||
|
- [ ] Email notifications
|
||||||
|
- [ ] Real-time collaboration (WebSockets)
|
||||||
|
- [ ] Full-text search with Elasticsearch
|
||||||
|
- [ ] Export to PDF/Markdown
|
||||||
|
- [ ] Mobile applications
|
||||||
|
- [ ] Plugin system
|
||||||
|
- [ ] Advanced permissions management
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License - See LICENSE file
|
||||||
|
|
||||||
|
## 👥 Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Commit your changes
|
||||||
|
4. Push to the branch
|
||||||
|
5. Create a Pull Request
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built with ❤️ for secure, collaborative note-taking**
|
||||||
284
SECURITY.md
Normal file
284
SECURITY.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# Security Implementation Guide
|
||||||
|
|
||||||
|
This document outlines the security measures implemented in Notely.
|
||||||
|
|
||||||
|
## 🔐 Authentication Security
|
||||||
|
|
||||||
|
### Password Hashing
|
||||||
|
|
||||||
|
- **Algorithm**: Argon2id (memory-hard, resistant to GPU attacks)
|
||||||
|
- **Configuration**:
|
||||||
|
- Memory: 64 MB
|
||||||
|
- Time: 1 iteration
|
||||||
|
- Parallelism: 4 threads
|
||||||
|
- Salt: 16 random bytes (cryptographically secure)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Generated hash format:
|
||||||
|
$argon2id$v=19$m=65536,t=1,p=4$salt_hex$hash_hex
|
||||||
|
```
|
||||||
|
|
||||||
|
### JWT Tokens
|
||||||
|
|
||||||
|
- **Algorithm**: HS256 (HMAC-SHA256)
|
||||||
|
- **Access Token TTL**: 1 hour
|
||||||
|
- **Refresh Token TTL**: 7 days (HTTP-only secure cookie)
|
||||||
|
- **Claims**:
|
||||||
|
- `user_id`: User's MongoDB ObjectID
|
||||||
|
- `email`: User's email address
|
||||||
|
- `username`: User's username
|
||||||
|
- `iat`: Issued at timestamp
|
||||||
|
- `exp`: Expiration timestamp
|
||||||
|
- `iss`: Issuer (verified against hardcoded value)
|
||||||
|
|
||||||
|
### Brute-Force Protection
|
||||||
|
|
||||||
|
- Track failed login attempts in `login_attempts` collection
|
||||||
|
- Rate limit: Max 5 failed attempts per IP per 15 minutes
|
||||||
|
- Account lockout: 15 minutes after 5 consecutive failures
|
||||||
|
- Cleanup: Expired records auto-deleted via TTL index
|
||||||
|
|
||||||
|
## 🛡️ Authorization Security
|
||||||
|
|
||||||
|
### Role-Based Access Control (RBAC)
|
||||||
|
|
||||||
|
```
|
||||||
|
Space Roles:
|
||||||
|
├── Owner (all permissions)
|
||||||
|
├── Editor (create/edit/delete notes)
|
||||||
|
└── Viewer (read-only)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Space-Level Data Isolation
|
||||||
|
|
||||||
|
**ALL queries include mandatory `space_id` filter**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Correct query pattern:
|
||||||
|
db.notes.find({ space_id: spaceID, ... })
|
||||||
|
|
||||||
|
// Never allow:
|
||||||
|
db.notes.find({ user_id: userID }) // ❌ Cross-space leak possible
|
||||||
|
```
|
||||||
|
|
||||||
|
### Middleware Authorization Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Extract JWT token → Verify signature & expiration
|
||||||
|
2. Load user credentials → Verify user is active
|
||||||
|
3. Check space membership → Verify user_id + space_id + role
|
||||||
|
4. Execute request → With space_id context
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔑 Data Encryption
|
||||||
|
|
||||||
|
### At Rest
|
||||||
|
|
||||||
|
- OAuth client secrets encrypted with AES-256-GCM
|
||||||
|
- Stored in MongoDB with encryption key in environment variables
|
||||||
|
- Decryption happens only when reading from database
|
||||||
|
|
||||||
|
```go
|
||||||
|
plaintext, err := encryptor.Encrypt(clientSecret) // Stores encrypted blob
|
||||||
|
recovered, err := encryptor.Decrypt(plaintext) // Decrypts on retrieval
|
||||||
|
```
|
||||||
|
|
||||||
|
### In Transit
|
||||||
|
|
||||||
|
- HTTPS/TLS required in production (enforced via Nginx)
|
||||||
|
- Secure cookies: `Secure`, `HttpOnly`, `SameSite=Lax` flags
|
||||||
|
- All sensitive data transmitted over encrypted channels
|
||||||
|
|
||||||
|
## 🚨 Input Validation
|
||||||
|
|
||||||
|
### Backend Validation (MANDATORY)
|
||||||
|
|
||||||
|
Every endpoint validates:
|
||||||
|
|
||||||
|
1. **Type validation** - JSON schema validation
|
||||||
|
2. **Length limits** - min/max string lengths
|
||||||
|
3. **Format validation** - email, ObjectID, URL formats
|
||||||
|
4. **Range validation** - pagination limits
|
||||||
|
|
||||||
|
```go
|
||||||
|
type CreateNoteRequest struct {
|
||||||
|
Title string `validate:"required,min=1,max=255"`
|
||||||
|
Content string `validate:"max=50000"`
|
||||||
|
Tags []string `validate:"max=100,dive,max=50"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Validation
|
||||||
|
|
||||||
|
- **Input sanitization** - trim whitespace
|
||||||
|
- **Format validation** - regex patterns
|
||||||
|
- **Debounced searches** - prevent query spam
|
||||||
|
- **Client-side feedback** - improve UX
|
||||||
|
|
||||||
|
### Output Sanitization
|
||||||
|
|
||||||
|
Markdown → HTML conversion sanitized with DOMPurify:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// XSS prevention
|
||||||
|
const dirty = marked.parse(userMarkdown);
|
||||||
|
const clean = DOMPurify.sanitize(dirty);
|
||||||
|
|
||||||
|
// Blocks: scripts, event handlers, dangerous attributes
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 Web Security Headers
|
||||||
|
|
||||||
|
Implemented via Nginx and Go middleware:
|
||||||
|
|
||||||
|
| Header | Value | Purpose |
|
||||||
|
| --------------------------- | --------------------------------- | ------------------------------- |
|
||||||
|
| `Strict-Transport-Security` | `max-age=31536000` | Force HTTPS |
|
||||||
|
| `X-Content-Type-Options` | `nosniff` | Prevent MIME sniffing |
|
||||||
|
| `X-Frame-Options` | `DENY` | Prevent clickjacking |
|
||||||
|
| `X-XSS-Protection` | `1; mode=block` | XSS protection (older browsers) |
|
||||||
|
| `Content-Security-Policy` | Restrictive policy | Prevent XSS attacks |
|
||||||
|
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Referrer control |
|
||||||
|
|
||||||
|
**CSP Policy:**
|
||||||
|
|
||||||
|
```
|
||||||
|
default-src 'self'
|
||||||
|
script-src 'self' 'unsafe-inline' (for development only)
|
||||||
|
style-src 'self' 'unsafe-inline'
|
||||||
|
img-src 'self' data: https:
|
||||||
|
font-src 'self'
|
||||||
|
connect-src 'self'
|
||||||
|
frame-ancestors 'none'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🍪 Cookie Security
|
||||||
|
|
||||||
|
### Access Token (via Authorization header)
|
||||||
|
|
||||||
|
- Stored in **memory** (not localStorage)
|
||||||
|
- Passed via `Authorization: Bearer {token}`
|
||||||
|
|
||||||
|
### Refresh Token (HTTP-only cookie)
|
||||||
|
|
||||||
|
```go
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "refresh_token",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 7 * 24 * 60 * 60, // 7 days
|
||||||
|
HttpOnly: true, // ✅ Cannot access from JavaScript
|
||||||
|
Secure: true, // ✅ HTTPS only
|
||||||
|
SameSite: http.SameSiteLaxMode, // ✅ CSRF protection
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Rate Limiting
|
||||||
|
|
||||||
|
### API Rate Limiting
|
||||||
|
|
||||||
|
- **General**: 50 requests / second per IP
|
||||||
|
- **Login**: 10 requests / second per IP
|
||||||
|
- **Burst allowance**: 20 additional requests
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||||
|
limit_req zone=api_limit burst=20 nodelay;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Login Attempt Tracking
|
||||||
|
|
||||||
|
- Track per email + IP combination
|
||||||
|
- Maximum 5 attempts per 15 minutes
|
||||||
|
- Exponential backoff on repeated failures
|
||||||
|
|
||||||
|
## 🔒 Database Security
|
||||||
|
|
||||||
|
### MongoDB
|
||||||
|
|
||||||
|
- **Authentication**: Username/password with role-based access
|
||||||
|
- **Network**: Runs in secure Docker network (not exposed)
|
||||||
|
- **Admin credentials**: Stored in Kubernetes Secrets (not in code)
|
||||||
|
- **Backups**: TBD - use MongoDB Atlas or encrypted backups
|
||||||
|
|
||||||
|
### Connection String
|
||||||
|
|
||||||
|
```
|
||||||
|
mongodb://admin:password@mongodb:27017/dbname?authSource=admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Logging & Monitoring
|
||||||
|
|
||||||
|
### Security Events Logged
|
||||||
|
|
||||||
|
- ✅ User registration attempts
|
||||||
|
- ✅ Login attempts (success/failure)
|
||||||
|
- ✅ Authorization failures
|
||||||
|
- ✅ Permission denied events
|
||||||
|
- ✅ Sensitive data access
|
||||||
|
|
||||||
|
### Data NOT logged
|
||||||
|
|
||||||
|
- ❌ Passwords/hashes
|
||||||
|
- ❌ JWT tokens
|
||||||
|
- ❌ Encryption keys
|
||||||
|
- ❌ OAuth secrets
|
||||||
|
|
||||||
|
## 🧪 Security Testing
|
||||||
|
|
||||||
|
### What to Test
|
||||||
|
|
||||||
|
1. **Authentication**: Register, login, token refresh, logout
|
||||||
|
2. **Authorization**: RBAC enforcement, space isolation
|
||||||
|
3. **Input validation**: Invalid data rejection
|
||||||
|
4. **XSS prevention**: Markdown sanitization
|
||||||
|
5. **CSRF protection**: Token validation
|
||||||
|
6. **Rate limiting**: Too many requests blocked
|
||||||
|
7. **SQL Injection**: MongoDB-specific (parameterized queries safe)
|
||||||
|
|
||||||
|
### Manual Testing Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test invalid input
|
||||||
|
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||||
|
-d '{"email":"not-an-email","password":""}'
|
||||||
|
|
||||||
|
# Test expired token
|
||||||
|
curl -H "Authorization: Bearer expired.token.here" \
|
||||||
|
http://localhost:8080/api/v1/spaces
|
||||||
|
|
||||||
|
# Test rate limiting
|
||||||
|
for i in {1..100}; do
|
||||||
|
curl http://localhost:8080/api/v1/auth/login &
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Production Checklist
|
||||||
|
|
||||||
|
- [ ] Change default JWT_SECRET (min 32 characters)
|
||||||
|
- [ ] Change default ENCRYPTION_KEY (32 bytes)
|
||||||
|
- [ ] Generate TLS certificates (Let's Encrypt recommended)
|
||||||
|
- [ ] Configure Nginx SSL/TLS
|
||||||
|
- [ ] Enable HTTPS redirect
|
||||||
|
- [ ] Set up database backups
|
||||||
|
- [ ] Configure logging & monitoring
|
||||||
|
- [ ] Implement CORS whitelist (specific domains)
|
||||||
|
- [ ] Set up rate limiting (tuned to your traffic)
|
||||||
|
- [ ] Enable database authentication
|
||||||
|
- [ ] Use Kubernetes Network Policies
|
||||||
|
- [ ] Set up Pod Security Policies
|
||||||
|
- [ ] Enable audit logging
|
||||||
|
- [ ] Configure Secrets encryption at rest
|
||||||
|
|
||||||
|
## 📚 References
|
||||||
|
|
||||||
|
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||||
|
- [MongoDB Security](https://docs.mongodb.com/manual/security/)
|
||||||
|
- [JWT Best Practices](https://tools.ietf.org/html/rfc8949)
|
||||||
|
- [Argon2 Specification](https://github.com/P-H-C/phc-winner-argon2)
|
||||||
|
- [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: March 2026
|
||||||
|
**Security Level**: Production-Grade
|
||||||
28
backend/.env.example
Normal file
28
backend/.env.example
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Backend Environment Example
|
||||||
|
|
||||||
|
# MongoDB
|
||||||
|
MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp?authSource=admin
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters-change-in-production
|
||||||
|
JWT_ISSUER=noteapp
|
||||||
|
|
||||||
|
# Encryption Key (32 bytes/characters for AES-256)
|
||||||
|
ENCRYPTION_KEY=00000000000000000000000000000000
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=8080
|
||||||
|
ENV=development
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Default Admin
|
||||||
|
DEFAULT_ADMIN_EMAIL=admin@notely.local
|
||||||
|
DEFAULT_ADMIN_USERNAME=admin
|
||||||
|
DEFAULT_ADMIN_PASSWORD=ChangeThisAdminPassword123!
|
||||||
|
|
||||||
|
# CORS (comma-separated origins)
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_REQUESTS=50
|
||||||
|
RATE_LIMIT_WINDOW=1s
|
||||||
433
backend/cmd/server/main.go
Normal file
433
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/noteapp/backend/internal/application/services"
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
"github.com/noteapp/backend/internal/domain/repositories"
|
||||||
|
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||||
|
"github.com/noteapp/backend/internal/infrastructure/database"
|
||||||
|
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||||
|
"github.com/noteapp/backend/internal/interfaces/handlers"
|
||||||
|
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load environment variables
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
mongoURL := os.Getenv("MONGODB_URI")
|
||||||
|
if mongoURL == "" {
|
||||||
|
mongoURL = "mongodb://localhost:27017"
|
||||||
|
}
|
||||||
|
|
||||||
|
jwtSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if jwtSecret == "" {
|
||||||
|
jwtSecret = "your-secret-key-change-in-production"
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptionKey := os.Getenv("ENCRYPTION_KEY")
|
||||||
|
if encryptionKey == "" {
|
||||||
|
encryptionKey = "00000000000000000000000000000000" // 32 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to database
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
db, err := database.NewDatabase(ctx, mongoURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close(context.Background())
|
||||||
|
|
||||||
|
// Initialize security components
|
||||||
|
passwordHasher := security.NewPasswordHasher()
|
||||||
|
encryptor, err := security.NewEncryptor(encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize encryptor: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize JWT manager
|
||||||
|
jwtManager := auth.NewJWTManager(jwtSecret, "noteapp", 1*time.Hour)
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
permissionService := services.NewPermissionService(
|
||||||
|
db.UserRepo,
|
||||||
|
db.GroupRepo,
|
||||||
|
db.MembershipRepo,
|
||||||
|
db.SpaceRepo,
|
||||||
|
)
|
||||||
|
|
||||||
|
authService := services.NewAuthService(
|
||||||
|
db.UserRepo,
|
||||||
|
db.GroupRepo,
|
||||||
|
db.ProviderRepo,
|
||||||
|
db.LinkRepo,
|
||||||
|
db.RecoveryRepo,
|
||||||
|
db.FeatureFlagRepo,
|
||||||
|
permissionService,
|
||||||
|
jwtManager,
|
||||||
|
passwordHasher,
|
||||||
|
encryptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
spaceService := services.NewSpaceService(
|
||||||
|
db.SpaceRepo,
|
||||||
|
db.MembershipRepo,
|
||||||
|
db.NoteRepo,
|
||||||
|
db.CategoryRepo,
|
||||||
|
db.UserRepo,
|
||||||
|
permissionService,
|
||||||
|
)
|
||||||
|
|
||||||
|
noteService := services.NewNoteService(
|
||||||
|
db.NoteRepo,
|
||||||
|
db.CategoryRepo,
|
||||||
|
db.MembershipRepo,
|
||||||
|
nil, // NoteRevisionRepository
|
||||||
|
db.SpaceRepo,
|
||||||
|
permissionService,
|
||||||
|
passwordHasher,
|
||||||
|
)
|
||||||
|
|
||||||
|
categoryService := services.NewCategoryService(
|
||||||
|
db.CategoryRepo,
|
||||||
|
db.MembershipRepo,
|
||||||
|
db.NoteRepo,
|
||||||
|
permissionService,
|
||||||
|
)
|
||||||
|
|
||||||
|
adminService := services.NewAdminService(
|
||||||
|
db.UserRepo,
|
||||||
|
db.GroupRepo,
|
||||||
|
db.SpaceRepo,
|
||||||
|
db.MembershipRepo,
|
||||||
|
db.NoteRepo,
|
||||||
|
db.CategoryRepo,
|
||||||
|
db.FeatureFlagRepo,
|
||||||
|
permissionService,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := permissionService.EnsureAdminGroup(context.Background()); err != nil {
|
||||||
|
log.Fatalf("failed to initialize admin group: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ensureDefaultAdminUser(
|
||||||
|
context.Background(),
|
||||||
|
db.UserRepo,
|
||||||
|
db.GroupRepo,
|
||||||
|
permissionService,
|
||||||
|
passwordHasher,
|
||||||
|
); err != nil {
|
||||||
|
log.Fatalf("failed to initialize default admin user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize handlers
|
||||||
|
authHandler := handlers.NewAuthHandler(authService)
|
||||||
|
spaceHandler := handlers.NewSpaceHandler(spaceService)
|
||||||
|
noteHandler := handlers.NewNoteHandler(noteService)
|
||||||
|
categoryHandler := handlers.NewCategoryHandler(categoryService)
|
||||||
|
adminHandler := handlers.NewAdminHandler(adminService)
|
||||||
|
publicHandler := handlers.NewPublicHandler(spaceService, noteService)
|
||||||
|
settingsHandler := handlers.NewSettingsHandler(authService)
|
||||||
|
|
||||||
|
// Create router
|
||||||
|
router := mux.NewRouter()
|
||||||
|
router.PathPrefix("/").Methods(http.MethodOptions).HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
authMiddleware := middleware.NewAuthMiddleware(jwtManager)
|
||||||
|
router.Use(middleware.LoggingMiddleware)
|
||||||
|
router.Use(middleware.CORSMiddleware)
|
||||||
|
router.Use(middleware.SecurityHeaders)
|
||||||
|
|
||||||
|
// Public endpoints
|
||||||
|
router.HandleFunc("/health", authHandler.Health).Methods("GET")
|
||||||
|
router.HandleFunc("/api/v1/auth/register", authHandler.Register).Methods("POST")
|
||||||
|
router.HandleFunc("/api/v1/auth/login", authHandler.Login).Methods("POST")
|
||||||
|
router.HandleFunc("/api/v1/auth/refresh", authHandler.RefreshToken).Methods("POST")
|
||||||
|
router.HandleFunc("/api/v1/auth/logout", authHandler.Logout).Methods("POST")
|
||||||
|
router.HandleFunc("/api/v1/auth/providers", authHandler.ListProviders).Methods("GET")
|
||||||
|
router.HandleFunc("/api/v1/auth/providers/{providerId}/start", authHandler.StartProviderLogin).Methods("GET")
|
||||||
|
router.HandleFunc("/api/v1/auth/providers/{providerId}/callback", authHandler.CompleteProviderLogin).Methods("GET")
|
||||||
|
router.HandleFunc("/api/v1/settings/feature-flags", settingsHandler.GetFeatureFlags).Methods("GET")
|
||||||
|
|
||||||
|
// Public read-only endpoints (no auth required)
|
||||||
|
public := router.PathPrefix("/api/v1/public").Subrouter()
|
||||||
|
public.HandleFunc("/spaces", publicHandler.ListPublicSpaces).Methods("GET")
|
||||||
|
public.HandleFunc("/spaces/{spaceId}", publicHandler.GetPublicSpace).Methods("GET")
|
||||||
|
public.HandleFunc("/spaces/{spaceId}/notes", publicHandler.GetPublicNotes).Methods("GET")
|
||||||
|
public.HandleFunc("/spaces/{spaceId}/notes/{noteId}", publicHandler.GetPublicNote).Methods("GET")
|
||||||
|
public.HandleFunc("/spaces/{spaceId}/notes/{noteId}/unlock", publicHandler.UnlockPublicNote).Methods("POST")
|
||||||
|
|
||||||
|
// Protected endpoints
|
||||||
|
api := router.PathPrefix("/api/v1").Subrouter()
|
||||||
|
api.Use(authMiddleware.Middleware)
|
||||||
|
|
||||||
|
// Space endpoints
|
||||||
|
api.HandleFunc("/spaces", spaceHandler.GetUserSpaces).Methods("GET")
|
||||||
|
api.HandleFunc("/spaces", spaceHandler.CreateSpace).Methods("POST")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}", spaceHandler.GetSpace).Methods("GET")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}", spaceHandler.UpdateSpace).Methods("PUT")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}", spaceHandler.DeleteSpace).Methods("DELETE")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/members", spaceHandler.AddMember).Methods("POST")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/members", spaceHandler.GetSpaceMembers).Methods("GET")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/members/{userId}", spaceHandler.RemoveMember).Methods("DELETE")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/available-users", spaceHandler.GetAvailableUsers).Methods("GET")
|
||||||
|
|
||||||
|
// Note endpoints
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/notes", noteHandler.GetNotesBySpace).Methods("GET")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/notes", noteHandler.CreateNote).Methods("POST")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/notes/search", noteHandler.SearchNotes).Methods("GET")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}", noteHandler.GetNote).Methods("GET")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/unlock", noteHandler.UnlockNote).Methods("POST")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}", noteHandler.UpdateNote).Methods("PUT")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}", noteHandler.DeleteNote).Methods("DELETE")
|
||||||
|
|
||||||
|
// Category endpoints
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/categories", categoryHandler.GetCategoryTree).Methods("GET")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/categories", categoryHandler.CreateCategory).Methods("POST")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.UpdateCategory).Methods("PUT")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH")
|
||||||
|
|
||||||
|
// Admin endpoints
|
||||||
|
admin := router.PathPrefix("/api/v1/admin").Subrouter()
|
||||||
|
admin.Use(authMiddleware.Middleware)
|
||||||
|
admin.Use(func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userIDHex, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := bson.ObjectIDFromHex(userIDHex)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := permissionService.UserHasPermission(r.Context(), userID, "admin.access")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
allowed, err = permissionService.UserHasPermission(r.Context(), userID, "*")
|
||||||
|
if err != nil || !allowed {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
admin.HandleFunc("/users", adminHandler.ListUsers).Methods("GET")
|
||||||
|
admin.HandleFunc("/users/{userId}/groups", adminHandler.UpdateUserGroups).Methods("PUT")
|
||||||
|
admin.HandleFunc("/groups", adminHandler.ListGroups).Methods("GET")
|
||||||
|
admin.HandleFunc("/groups", adminHandler.CreateGroup).Methods("POST")
|
||||||
|
admin.HandleFunc("/groups/{groupId}", adminHandler.UpdateGroup).Methods("PUT")
|
||||||
|
admin.HandleFunc("/spaces", adminHandler.ListAllSpaces).Methods("GET")
|
||||||
|
admin.HandleFunc("/spaces/{spaceId}", adminHandler.UpdateSpace).Methods("PUT")
|
||||||
|
admin.HandleFunc("/spaces/{spaceId}", adminHandler.DeleteSpace).Methods("DELETE")
|
||||||
|
admin.HandleFunc("/spaces/{spaceId}/members", adminHandler.AddSpaceMember).Methods("POST")
|
||||||
|
admin.HandleFunc("/spaces/{spaceId}/members", adminHandler.ListSpaceMembers).Methods("GET")
|
||||||
|
admin.HandleFunc("/spaces/{spaceId}/members/{userId}", adminHandler.RemoveSpaceMember).Methods("DELETE")
|
||||||
|
admin.HandleFunc("/spaces/{spaceId}/visibility", adminHandler.SetSpaceVisibility).Methods("PUT")
|
||||||
|
admin.HandleFunc("/feature-flags", adminHandler.GetFeatureFlags).Methods("GET")
|
||||||
|
admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT")
|
||||||
|
// manage identity providers — admin-only
|
||||||
|
admin.HandleFunc("/auth/providers", authHandler.CreateProvider).Methods("POST")
|
||||||
|
|
||||||
|
// Serve static files (frontend) for all other routes
|
||||||
|
// This must be after all API route handlers to allow API routes to take precedence
|
||||||
|
router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// List of static file extensions to serve directly
|
||||||
|
staticExts := map[string]bool{
|
||||||
|
".js": true, ".css": true, ".svg": true, ".png": true,
|
||||||
|
".jpg": true, ".jpeg": true, ".gif": true, ".ico": true,
|
||||||
|
".woff": true, ".woff2": true, ".ttf": true, ".eot": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := "./public" + r.URL.Path
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
filePath = "./public/index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a static file (has an extension in staticExts)
|
||||||
|
isStatic := false
|
||||||
|
for ext := range staticExts {
|
||||||
|
if len(r.URL.Path) > len(ext) {
|
||||||
|
if r.URL.Path[len(r.URL.Path)-len(ext):] == ext {
|
||||||
|
isStatic = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it doesn't look like a static file, serve index.html (SPA routing)
|
||||||
|
if !isStatic {
|
||||||
|
filePath = "./public/index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeFile(w, r, filePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: ":" + port,
|
||||||
|
Handler: router,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Server starting on port %s\n", port)
|
||||||
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("Server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDefaultAdminUser(
|
||||||
|
ctx context.Context,
|
||||||
|
userRepo repositories.UserRepository,
|
||||||
|
groupRepo repositories.GroupRepository,
|
||||||
|
permissionService *services.PermissionService,
|
||||||
|
passwordHasher *security.PasswordHasher,
|
||||||
|
) error {
|
||||||
|
adminEmail := strings.ToLower(strings.TrimSpace(os.Getenv("DEFAULT_ADMIN_EMAIL")))
|
||||||
|
adminUsername := strings.TrimSpace(os.Getenv("DEFAULT_ADMIN_USERNAME"))
|
||||||
|
adminPassword := strings.TrimSpace(os.Getenv("DEFAULT_ADMIN_PASSWORD"))
|
||||||
|
adminFirstName := "System"
|
||||||
|
adminLastName := "Admin"
|
||||||
|
|
||||||
|
if adminEmail == "" && adminUsername == "" && adminPassword == "" {
|
||||||
|
log.Println("default admin bootstrap skipped (DEFAULT_ADMIN_* env vars not set)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if adminEmail == "" || adminUsername == "" || adminPassword == "" {
|
||||||
|
return errors.New("DEFAULT_ADMIN_EMAIL, DEFAULT_ADMIN_USERNAME and DEFAULT_ADMIN_PASSWORD must all be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(adminPassword) < 8 {
|
||||||
|
return errors.New("DEFAULT_ADMIN_PASSWORD must be at least 8 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
adminGroup, err := groupRepo.GetGroupByName(ctx, "Admin")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := userRepo.GetUserByEmail(ctx, adminEmail)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() != "user not found" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingUsernameUser, usernameErr := userRepo.GetUserByUsername(ctx, adminUsername); usernameErr == nil && existingUsernameUser != nil {
|
||||||
|
return errors.New("DEFAULT_ADMIN_USERNAME already belongs to another user")
|
||||||
|
} else if usernameErr != nil && usernameErr.Error() != "user not found" {
|
||||||
|
return usernameErr
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, hashErr := passwordHasher.HashPassword(adminPassword)
|
||||||
|
if hashErr != nil {
|
||||||
|
return hashErr
|
||||||
|
}
|
||||||
|
|
||||||
|
user = &entities.User{
|
||||||
|
Email: adminEmail,
|
||||||
|
Username: adminUsername,
|
||||||
|
PasswordHash: hashedPassword,
|
||||||
|
FirstName: adminFirstName,
|
||||||
|
LastName: adminLastName,
|
||||||
|
GroupIDs: []bson.ObjectID{adminGroup.ID},
|
||||||
|
IsActive: true,
|
||||||
|
EmailVerified: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if createErr := userRepo.CreateUser(ctx, user); createErr != nil {
|
||||||
|
return createErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if permissionService != nil {
|
||||||
|
if permErr := permissionService.UpdateUserEffectivePermissions(ctx, user); permErr != nil {
|
||||||
|
return permErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("default admin user created: %s", adminEmail)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := false
|
||||||
|
if !user.IsActive {
|
||||||
|
user.IsActive = true
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAdminGroup := false
|
||||||
|
for _, groupID := range user.GroupIDs {
|
||||||
|
if groupID == adminGroup.ID {
|
||||||
|
hasAdminGroup = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasAdminGroup {
|
||||||
|
user.GroupIDs = append(user.GroupIDs, adminGroup.ID)
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordMatches, verifyErr := passwordHasher.VerifyPassword(adminPassword, user.PasswordHash)
|
||||||
|
if verifyErr != nil {
|
||||||
|
return verifyErr
|
||||||
|
}
|
||||||
|
if !passwordMatches {
|
||||||
|
hashedPassword, hashErr := passwordHasher.HashPassword(adminPassword)
|
||||||
|
if hashErr != nil {
|
||||||
|
return hashErr
|
||||||
|
}
|
||||||
|
user.PasswordHash = hashedPassword
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !modified {
|
||||||
|
log.Printf("default admin user already initialized: %s", adminEmail)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if permissionService != nil {
|
||||||
|
if err := permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := userRepo.UpdateUser(ctx, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("default admin user synchronized from environment: %s", adminEmail)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
23
backend/go.mod
Normal file
23
backend/go.mod
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
module github.com/noteapp/backend
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||||
|
github.com/gorilla/mux v1.8.1
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
|
golang.org/x/crypto v0.49.0
|
||||||
|
golang.org/x/oauth2 v0.30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/klauspost/compress v1.17.6 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
)
|
||||||
56
backend/go.sum
Normal file
56
backend/go.sum
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||||
|
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
|
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
440
backend/internal/application/dto/dto.go
Normal file
440
backend/internal/application/dto/dto.go
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== AUTH DTOs ==========
|
||||||
|
|
||||||
|
// RegisterRequest represents a registration request
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Username string `json:"username" validate:"required,min=3,max=20"`
|
||||||
|
Password string `json:"password" validate:"required,min=8"`
|
||||||
|
PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password"`
|
||||||
|
FirstName string `json:"first_name" validate:"max=50"`
|
||||||
|
LastName string `json:"last_name" validate:"max=50"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRequest represents a login request
|
||||||
|
type LoginRequest struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Password string `json:"password" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginResponse represents a login response
|
||||||
|
type LoginResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
User *UserDTO `json:"user"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthProviderDTO represents an OAuth/OIDC provider in API responses.
|
||||||
|
type AuthProviderDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
AuthorizationURL string `json:"authorization_url,omitempty"`
|
||||||
|
TokenURL string `json:"token_url,omitempty"`
|
||||||
|
UserInfoURL string `json:"userinfo_url,omitempty"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
IDTokenClaim string `json:"id_token_claim,omitempty"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAuthProviderRequest represents an OAuth/OIDC provider creation request.
|
||||||
|
type CreateAuthProviderRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientSecret string `json:"client_secret"`
|
||||||
|
AuthorizationURL string `json:"authorization_url"`
|
||||||
|
TokenURL string `json:"token_url"`
|
||||||
|
UserInfoURL string `json:"userinfo_url"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
IDTokenClaim string `json:"id_token_claim,omitempty"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeatureFlagsDTO represents app-wide feature flags in API responses.
|
||||||
|
type FeatureFlagsDTO struct {
|
||||||
|
RegistrationEnabled bool `json:"registration_enabled"`
|
||||||
|
ProviderLoginEnabled bool `json:"provider_login_enabled"`
|
||||||
|
PublicSharingEnabled bool `json:"public_sharing_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFeatureFlagsRequest represents admin payload for feature flag updates.
|
||||||
|
type UpdateFeatureFlagsRequest struct {
|
||||||
|
RegistrationEnabled bool `json:"registration_enabled"`
|
||||||
|
ProviderLoginEnabled bool `json:"provider_login_enabled"`
|
||||||
|
PublicSharingEnabled bool `json:"public_sharing_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDTO represents a user in API responses
|
||||||
|
type UserDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
Avatar string `json:"avatar,omitempty"`
|
||||||
|
GroupIDs []string `json:"group_ids,omitempty"`
|
||||||
|
Permissions []string `json:"permissions,omitempty"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserDTO creates a DTO from a user entity
|
||||||
|
func NewUserDTO(user *entities.User) *UserDTO {
|
||||||
|
groupIDs := make([]string, 0, len(user.GroupIDs))
|
||||||
|
for _, groupID := range user.GroupIDs {
|
||||||
|
groupIDs = append(groupIDs, groupID.Hex())
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UserDTO{
|
||||||
|
ID: user.ID.Hex(),
|
||||||
|
Email: user.Email,
|
||||||
|
Username: user.Username,
|
||||||
|
FirstName: user.FirstName,
|
||||||
|
LastName: user.LastName,
|
||||||
|
Avatar: user.Avatar,
|
||||||
|
GroupIDs: groupIDs,
|
||||||
|
Permissions: user.Permissions,
|
||||||
|
EmailVerified: user.EmailVerified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUserDTO extends UserDTO with admin-visible fields
|
||||||
|
type AdminUserDTO struct {
|
||||||
|
*UserDTO
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PermissionGroupDTO represents a permission group in API responses.
|
||||||
|
type PermissionGroupDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
IsSystem bool `json:"is_system"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePermissionGroupRequest represents group creation input.
|
||||||
|
type CreatePermissionGroupRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePermissionGroupRequest represents group update input.
|
||||||
|
type UpdatePermissionGroupRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserGroupsRequest represents user group assignment input.
|
||||||
|
type UpdateUserGroupsRequest struct {
|
||||||
|
GroupIDs []string `json:"group_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminUserDTO creates an admin DTO from a user entity
|
||||||
|
func NewAdminUserDTO(user *entities.User) *AdminUserDTO {
|
||||||
|
return &AdminUserDTO{
|
||||||
|
UserDTO: NewUserDTO(user),
|
||||||
|
IsActive: user.IsActive,
|
||||||
|
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPermissionGroupDTO creates a DTO from a permission group entity.
|
||||||
|
func NewPermissionGroupDTO(group *entities.PermissionGroup) *PermissionGroupDTO {
|
||||||
|
return &PermissionGroupDTO{
|
||||||
|
ID: group.ID.Hex(),
|
||||||
|
Name: group.Name,
|
||||||
|
Description: group.Description,
|
||||||
|
Permissions: group.Permissions,
|
||||||
|
IsSystem: group.IsSystem,
|
||||||
|
CreatedAt: group.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: group.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSpaceMemberRequest represents a request to add a member to a space
|
||||||
|
type AddSpaceMemberRequest struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpaceMemberDTO represents a member in a space
|
||||||
|
type SpaceMemberDTO struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
JoinedAt string `json:"joined_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserOptionDTO is a lightweight user object for dropdowns
|
||||||
|
type UserOptionDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthProviderDTO creates a DTO from an auth provider entity.
|
||||||
|
func NewAuthProviderDTO(provider *entities.AuthProvider) *AuthProviderDTO {
|
||||||
|
return &AuthProviderDTO{
|
||||||
|
ID: provider.ID.Hex(),
|
||||||
|
Name: provider.Name,
|
||||||
|
Type: provider.Type,
|
||||||
|
AuthorizationURL: provider.AuthorizationURL,
|
||||||
|
TokenURL: provider.TokenURL,
|
||||||
|
UserInfoURL: provider.UserInfoURL,
|
||||||
|
Scopes: provider.Scopes,
|
||||||
|
IDTokenClaim: provider.IDTokenClaim,
|
||||||
|
IsActive: provider.IsActive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFeatureFlagsDTO creates a DTO from feature flags entity.
|
||||||
|
func NewFeatureFlagsDTO(flags *entities.FeatureFlags) *FeatureFlagsDTO {
|
||||||
|
if flags == nil {
|
||||||
|
flags = entities.NewDefaultFeatureFlags()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FeatureFlagsDTO{
|
||||||
|
RegistrationEnabled: flags.RegistrationEnabled,
|
||||||
|
ProviderLoginEnabled: flags.ProviderLoginEnabled,
|
||||||
|
PublicSharingEnabled: flags.PublicSharingEnabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== SPACE DTOs ==========
|
||||||
|
|
||||||
|
// CreateSpaceRequest represents a space creation request
|
||||||
|
type CreateSpaceRequest struct {
|
||||||
|
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||||
|
Description string `json:"description" validate:"max=500"`
|
||||||
|
Icon string `json:"icon,omitempty" validate:"max=20"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpaceDTO represents a space in API responses
|
||||||
|
type SpaceDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PermissionKey string `json:"permission_key"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
OwnerID string `json:"owner_id"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSpaceDTO creates a DTO from a space entity
|
||||||
|
func NewSpaceDTO(space *entities.Space) *SpaceDTO {
|
||||||
|
dto := &SpaceDTO{
|
||||||
|
ID: space.ID.Hex(),
|
||||||
|
Name: space.Name,
|
||||||
|
PermissionKey: entities.SpacePermissionToken(space.Name),
|
||||||
|
Description: space.Description,
|
||||||
|
Icon: space.Icon,
|
||||||
|
OwnerID: space.OwnerID.Hex(),
|
||||||
|
IsPublic: space.IsPublic,
|
||||||
|
CreatedAt: space.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: space.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== NOTE DTOs ==========
|
||||||
|
|
||||||
|
// CreateNoteRequest represents a note creation request
|
||||||
|
type CreateNoteRequest struct {
|
||||||
|
Title string `json:"title" validate:"required,min=1,max=255"`
|
||||||
|
Description string `json:"description" validate:"max=500"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
NotePassword string `json:"note_password,omitempty" validate:"omitempty,min=4,max=128"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
CategoryID *string `json:"category_id,omitempty"`
|
||||||
|
IsPinned bool `json:"is_pinned"`
|
||||||
|
IsFavorite bool `json:"is_favorite"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNoteRequest represents a note update request
|
||||||
|
type UpdateNoteRequest struct {
|
||||||
|
Title string `json:"title" validate:"min=1,max=255"`
|
||||||
|
Description *string `json:"description,omitempty" validate:"omitempty,max=500"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
NotePassword *string `json:"note_password,omitempty" validate:"omitempty,max=128"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
CategoryID *string `json:"category_id,omitempty"`
|
||||||
|
IsPinned *bool `json:"is_pinned"`
|
||||||
|
IsFavorite *bool `json:"is_favorite"`
|
||||||
|
IsPublic *bool `json:"is_public,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockNoteRequest represents a password unlock request for protected notes
|
||||||
|
type UnlockNoteRequest struct {
|
||||||
|
Password string `json:"password" validate:"required,min=1,max=128"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteDTO represents a note in API responses
|
||||||
|
type NoteDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SpaceID string `json:"space_id"`
|
||||||
|
CategoryID *string `json:"category_id,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
IsPinned bool `json:"is_pinned"`
|
||||||
|
IsFavorite bool `json:"is_favorite"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
IsPasswordProtected bool `json:"is_password_protected"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
UpdatedBy string `json:"updated_by"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteListItemDTO represents a lightweight note payload for list/tree endpoints
|
||||||
|
type NoteListItemDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SpaceID string `json:"space_id"`
|
||||||
|
CategoryID *string `json:"category_id,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsPinned bool `json:"is_pinned"`
|
||||||
|
IsFavorite bool `json:"is_favorite"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
IsPasswordProtected bool `json:"is_password_protected"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNoteDTO creates a DTO from a note entity
|
||||||
|
func NewNoteDTO(note *entities.Note) *NoteDTO {
|
||||||
|
var categoryID *string
|
||||||
|
if note.CategoryID != nil {
|
||||||
|
id := note.CategoryID.Hex()
|
||||||
|
categoryID = &id
|
||||||
|
}
|
||||||
|
return &NoteDTO{
|
||||||
|
ID: note.ID.Hex(),
|
||||||
|
SpaceID: note.SpaceID.Hex(),
|
||||||
|
CategoryID: categoryID,
|
||||||
|
Title: note.Title,
|
||||||
|
Description: note.Description,
|
||||||
|
Content: note.Content,
|
||||||
|
Tags: note.Tags,
|
||||||
|
IsPinned: note.IsPinned,
|
||||||
|
IsFavorite: note.IsFavorite,
|
||||||
|
IsPublic: note.IsPublic,
|
||||||
|
IsPasswordProtected: note.IsPasswordProtected,
|
||||||
|
CreatedBy: note.CreatedBy.Hex(),
|
||||||
|
UpdatedBy: note.UpdatedBy.Hex(),
|
||||||
|
CreatedAt: note.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: note.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNoteListItemDTO creates a lightweight DTO from a note entity
|
||||||
|
func NewNoteListItemDTO(note *entities.Note) *NoteListItemDTO {
|
||||||
|
var categoryID *string
|
||||||
|
if note.CategoryID != nil {
|
||||||
|
id := note.CategoryID.Hex()
|
||||||
|
categoryID = &id
|
||||||
|
}
|
||||||
|
|
||||||
|
return &NoteListItemDTO{
|
||||||
|
ID: note.ID.Hex(),
|
||||||
|
SpaceID: note.SpaceID.Hex(),
|
||||||
|
CategoryID: categoryID,
|
||||||
|
Title: note.Title,
|
||||||
|
Description: note.Description,
|
||||||
|
IsPinned: note.IsPinned,
|
||||||
|
IsFavorite: note.IsFavorite,
|
||||||
|
IsPublic: note.IsPublic,
|
||||||
|
IsPasswordProtected: note.IsPasswordProtected,
|
||||||
|
UpdatedAt: note.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== CATEGORY DTOs ==========
|
||||||
|
|
||||||
|
// CreateCategoryRequest represents a category creation request
|
||||||
|
type CreateCategoryRequest struct {
|
||||||
|
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||||
|
Description string `json:"description" validate:"max=500"`
|
||||||
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty" validate:"max=20"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCategoryRequest represents a category update request
|
||||||
|
type UpdateCategoryRequest struct {
|
||||||
|
Name string `json:"name" validate:"min=1,max=100"`
|
||||||
|
Description string `json:"description" validate:"max=500"`
|
||||||
|
Icon string `json:"icon,omitempty" validate:"max=20"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryDTO represents a category in API responses
|
||||||
|
type CategoryDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SpaceID string `json:"space_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryTreeDTO represents a category with its subcategories and notes
|
||||||
|
type CategoryTreeDTO struct {
|
||||||
|
*CategoryDTO
|
||||||
|
Subcategories []*CategoryTreeDTO `json:"subcategories"`
|
||||||
|
Notes []*NoteListItemDTO `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCategoryDTO creates a DTO from a category entity
|
||||||
|
func NewCategoryDTO(category *entities.Category) *CategoryDTO {
|
||||||
|
var parentID *string
|
||||||
|
if category.ParentID != nil {
|
||||||
|
id := category.ParentID.Hex()
|
||||||
|
parentID = &id
|
||||||
|
}
|
||||||
|
return &CategoryDTO{
|
||||||
|
ID: category.ID.Hex(),
|
||||||
|
SpaceID: category.SpaceID.Hex(),
|
||||||
|
Name: category.Name,
|
||||||
|
Description: category.Description,
|
||||||
|
ParentID: parentID,
|
||||||
|
Icon: category.Icon,
|
||||||
|
Order: category.Order,
|
||||||
|
CreatedAt: category.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: category.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ERROR DTOs ==========
|
||||||
|
|
||||||
|
// ErrorResponse represents an error response
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationError represents a validation error
|
||||||
|
type ValidationError struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationErrorResponse represents multiple validation errors
|
||||||
|
type ValidationErrorResponse struct {
|
||||||
|
Errors []ValidationError `json:"errors"`
|
||||||
|
}
|
||||||
313
backend/internal/application/services/admin_service.go
Normal file
313
backend/internal/application/services/admin_service.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/application/dto"
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
"github.com/noteapp/backend/internal/domain/repositories"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminService handles admin-level operations
|
||||||
|
type AdminService struct {
|
||||||
|
userRepo repositories.UserRepository
|
||||||
|
groupRepo repositories.GroupRepository
|
||||||
|
spaceRepo repositories.SpaceRepository
|
||||||
|
membershipRepo repositories.MembershipRepository
|
||||||
|
noteRepo repositories.NoteRepository
|
||||||
|
categoryRepo repositories.CategoryRepository
|
||||||
|
featureFlagRepo repositories.FeatureFlagRepository
|
||||||
|
permissionService *PermissionService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminService creates a new AdminService
|
||||||
|
func NewAdminService(
|
||||||
|
userRepo repositories.UserRepository,
|
||||||
|
groupRepo repositories.GroupRepository,
|
||||||
|
spaceRepo repositories.SpaceRepository,
|
||||||
|
membershipRepo repositories.MembershipRepository,
|
||||||
|
noteRepo repositories.NoteRepository,
|
||||||
|
categoryRepo repositories.CategoryRepository,
|
||||||
|
featureFlagRepo repositories.FeatureFlagRepository,
|
||||||
|
permissionService *PermissionService,
|
||||||
|
) *AdminService {
|
||||||
|
return &AdminService{
|
||||||
|
userRepo: userRepo,
|
||||||
|
groupRepo: groupRepo,
|
||||||
|
spaceRepo: spaceRepo,
|
||||||
|
membershipRepo: membershipRepo,
|
||||||
|
noteRepo: noteRepo,
|
||||||
|
categoryRepo: categoryRepo,
|
||||||
|
featureFlagRepo: featureFlagRepo,
|
||||||
|
permissionService: permissionService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns all users as admin DTOs
|
||||||
|
func (s *AdminService) ListUsers(ctx context.Context) ([]*dto.AdminUserDTO, error) {
|
||||||
|
users, err := s.userRepo.ListAllUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*dto.AdminUserDTO, len(users))
|
||||||
|
for i, u := range users {
|
||||||
|
if s.permissionService != nil {
|
||||||
|
permissions, err := s.permissionService.GetUserEffectivePermissions(ctx, u)
|
||||||
|
if err == nil {
|
||||||
|
u.Permissions = permissions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[i] = dto.NewAdminUserDTO(u)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGroups returns all permission groups.
|
||||||
|
func (s *AdminService) ListGroups(ctx context.Context) ([]*dto.PermissionGroupDTO, error) {
|
||||||
|
groups, err := s.groupRepo.ListGroups(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*dto.PermissionGroupDTO, len(groups))
|
||||||
|
for i, group := range groups {
|
||||||
|
result[i] = dto.NewPermissionGroupDTO(group)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGroup creates a new permission group.
|
||||||
|
func (s *AdminService) CreateGroup(ctx context.Context, req *dto.CreatePermissionGroupRequest) (*dto.PermissionGroupDTO, error) {
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("group name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
group := &entities.PermissionGroup{
|
||||||
|
Name: name,
|
||||||
|
Description: strings.TrimSpace(req.Description),
|
||||||
|
Permissions: normalizePermissions(req.Permissions),
|
||||||
|
IsSystem: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.groupRepo.CreateGroup(ctx, group); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewPermissionGroupDTO(group), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGroup updates a permission group.
|
||||||
|
func (s *AdminService) UpdateGroup(ctx context.Context, groupID bson.ObjectID, req *dto.UpdatePermissionGroupRequest) (*dto.PermissionGroupDTO, error) {
|
||||||
|
group, err := s.groupRepo.GetGroupByID(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if group.IsSystem {
|
||||||
|
return nil, errors.New("system groups cannot be modified")
|
||||||
|
}
|
||||||
|
|
||||||
|
if name := strings.TrimSpace(req.Name); name != "" {
|
||||||
|
group.Name = name
|
||||||
|
}
|
||||||
|
group.Description = strings.TrimSpace(req.Description)
|
||||||
|
group.Permissions = normalizePermissions(req.Permissions)
|
||||||
|
|
||||||
|
if err := s.groupRepo.UpdateGroup(ctx, group); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.refreshAllUserPermissions(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewPermissionGroupDTO(group), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserGroups assigns groups to a user.
|
||||||
|
func (s *AdminService) UpdateUserGroups(ctx context.Context, userID bson.ObjectID, groupIDs []bson.ObjectID) (*dto.AdminUserDTO, error) {
|
||||||
|
if s.permissionService == nil {
|
||||||
|
return nil, errors.New("permission service unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.permissionService.SetUserGroups(ctx, userID, groupIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewAdminUserDTO(user), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) refreshAllUserPermissions(ctx context.Context) error {
|
||||||
|
if s.permissionService == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := s.userRepo.ListAllUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, user := range users {
|
||||||
|
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePermissions(permissions []string) []string {
|
||||||
|
unique := map[string]struct{}{}
|
||||||
|
result := make([]string, 0, len(permissions))
|
||||||
|
for _, permission := range permissions {
|
||||||
|
normalized := entities.NormalizePermission(permission)
|
||||||
|
if normalized == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := unique[normalized]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unique[normalized] = struct{}{}
|
||||||
|
result = append(result, normalized)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllSpaces returns all spaces
|
||||||
|
func (s *AdminService) ListAllSpaces(ctx context.Context) ([]*dto.SpaceDTO, error) {
|
||||||
|
spaces, err := s.spaceRepo.GetAllSpaces(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*dto.SpaceDTO, len(spaces))
|
||||||
|
for i, space := range spaces {
|
||||||
|
result[i] = dto.NewSpaceDTO(space)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSpace updates all editable space fields
|
||||||
|
func (s *AdminService) UpdateSpace(ctx context.Context, spaceID bson.ObjectID, req *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) {
|
||||||
|
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
space.Name = req.Name
|
||||||
|
space.Description = req.Description
|
||||||
|
space.Icon = req.Icon
|
||||||
|
space.IsPublic = req.IsPublic
|
||||||
|
|
||||||
|
if err := s.spaceRepo.UpdateSpace(ctx, space); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewSpaceDTO(space), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSpaceVisibility sets the is_public flag on a space
|
||||||
|
func (s *AdminService) SetSpaceVisibility(ctx context.Context, spaceID bson.ObjectID, isPublic bool) error {
|
||||||
|
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
space.IsPublic = isPublic
|
||||||
|
return s.spaceRepo.UpdateSpace(ctx, space)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSpaceMember adds a member in a space if not already present.
|
||||||
|
func (s *AdminService) AddSpaceMember(ctx context.Context, spaceID, userID bson.ObjectID) error {
|
||||||
|
existing, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.membershipRepo.CreateMembership(ctx, &entities.Membership{
|
||||||
|
UserID: userID,
|
||||||
|
SpaceID: spaceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSpaceMembers returns all members for a space
|
||||||
|
func (s *AdminService) ListSpaceMembers(ctx context.Context, spaceID bson.ObjectID) ([]*dto.SpaceMemberDTO, error) {
|
||||||
|
memberships, err := s.membershipRepo.GetSpaceMembers(ctx, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*dto.SpaceMemberDTO, 0, len(memberships))
|
||||||
|
for _, member := range memberships {
|
||||||
|
username := member.UserID.Hex()
|
||||||
|
if user, err := s.userRepo.GetUserByID(ctx, member.UserID); err == nil {
|
||||||
|
username = user.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, &dto.SpaceMemberDTO{
|
||||||
|
UserID: member.UserID.Hex(),
|
||||||
|
Username: username,
|
||||||
|
JoinedAt: member.JoinedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSpaceMember removes a member from a space.
|
||||||
|
func (s *AdminService) RemoveSpaceMember(ctx context.Context, spaceID, userID bson.ObjectID) error {
|
||||||
|
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.membershipRepo.DeleteMembership(ctx, membership.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSpace deletes a space and all associated data (admin, no permission check).
|
||||||
|
func (s *AdminService) DeleteSpace(ctx context.Context, spaceID bson.ObjectID) error {
|
||||||
|
if err := s.noteRepo.DeleteNotesBySpaceID(ctx, spaceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.spaceRepo.DeleteSpace(ctx, spaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeatureFlags returns current app-wide feature flags.
|
||||||
|
func (s *AdminService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) {
|
||||||
|
if s.featureFlagRepo == nil {
|
||||||
|
return dto.NewFeatureFlagsDTO(nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := s.featureFlagRepo.GetFeatureFlags(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewFeatureFlagsDTO(flags), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFeatureFlags updates app-wide feature flags.
|
||||||
|
func (s *AdminService) UpdateFeatureFlags(ctx context.Context, req *dto.UpdateFeatureFlagsRequest) (*dto.FeatureFlagsDTO, error) {
|
||||||
|
if s.featureFlagRepo == nil {
|
||||||
|
return nil, errors.New("feature flags are unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := &entities.FeatureFlags{
|
||||||
|
RegistrationEnabled: req.RegistrationEnabled,
|
||||||
|
ProviderLoginEnabled: req.ProviderLoginEnabled,
|
||||||
|
PublicSharingEnabled: req.PublicSharingEnabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.featureFlagRepo.UpdateFeatureFlags(ctx, flags); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewFeatureFlagsDTO(flags), nil
|
||||||
|
}
|
||||||
592
backend/internal/application/services/auth_service.go
Normal file
592
backend/internal/application/services/auth_service.go
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/application/dto"
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
"github.com/noteapp/backend/internal/domain/repositories"
|
||||||
|
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||||
|
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthService handles authentication operations
|
||||||
|
type AuthService struct {
|
||||||
|
userRepo repositories.UserRepository
|
||||||
|
groupRepo repositories.GroupRepository
|
||||||
|
providerRepo repositories.AuthProviderRepository
|
||||||
|
linkRepo repositories.UserProviderLinkRepository
|
||||||
|
recoveryRepo repositories.AccountRecoveryRepository
|
||||||
|
featureFlagRepo repositories.FeatureFlagRepository
|
||||||
|
permissionService *PermissionService
|
||||||
|
jwtManager *auth.JWTManager
|
||||||
|
passHasher *security.PasswordHasher
|
||||||
|
encryptor *security.Encryptor
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthService creates a new auth service
|
||||||
|
func NewAuthService(
|
||||||
|
userRepo repositories.UserRepository,
|
||||||
|
groupRepo repositories.GroupRepository,
|
||||||
|
providerRepo repositories.AuthProviderRepository,
|
||||||
|
linkRepo repositories.UserProviderLinkRepository,
|
||||||
|
recoveryRepo repositories.AccountRecoveryRepository,
|
||||||
|
featureFlagRepo repositories.FeatureFlagRepository,
|
||||||
|
permissionService *PermissionService,
|
||||||
|
jwtManager *auth.JWTManager,
|
||||||
|
passHasher *security.PasswordHasher,
|
||||||
|
encryptor *security.Encryptor,
|
||||||
|
) *AuthService {
|
||||||
|
return &AuthService{
|
||||||
|
userRepo: userRepo,
|
||||||
|
groupRepo: groupRepo,
|
||||||
|
providerRepo: providerRepo,
|
||||||
|
linkRepo: linkRepo,
|
||||||
|
recoveryRepo: recoveryRepo,
|
||||||
|
featureFlagRepo: featureFlagRepo,
|
||||||
|
permissionService: permissionService,
|
||||||
|
jwtManager: jwtManager,
|
||||||
|
passHasher: passHasher,
|
||||||
|
encryptor: encryptor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register registers a new user
|
||||||
|
func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*dto.LoginResponse, error) {
|
||||||
|
flags, err := s.GetFeatureFlags(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !flags.RegistrationEnabled {
|
||||||
|
return nil, errors.New("registration is currently disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
|
||||||
|
// Check if email already exists
|
||||||
|
_, err = s.userRepo.GetUserByEmail(ctx, req.Email)
|
||||||
|
if err == nil {
|
||||||
|
return nil, errors.New("email already registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username already exists
|
||||||
|
_, err = s.userRepo.GetUserByUsername(ctx, req.Username)
|
||||||
|
if err == nil {
|
||||||
|
return nil, errors.New("username already taken")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hashedPassword, err := s.passHasher.HashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
user := &entities.User{
|
||||||
|
Email: req.Email,
|
||||||
|
Username: req.Username,
|
||||||
|
PasswordHash: hashedPassword,
|
||||||
|
FirstName: req.FirstName,
|
||||||
|
LastName: req.LastName,
|
||||||
|
IsActive: true,
|
||||||
|
EmailVerified: false, // Should verify email in production
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.userRepo.CreateUser(ctx, user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.permissionService != nil {
|
||||||
|
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.LoginResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
User: dto.NewUserDTO(user),
|
||||||
|
ExpiresIn: 3600, // 1 hour
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates a user
|
||||||
|
func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest) (*dto.LoginResponse, error) {
|
||||||
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
|
||||||
|
// Get user by email
|
||||||
|
user, err := s.userRepo.GetUserByEmail(ctx, req.Email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsActive {
|
||||||
|
return nil, errors.New("account is inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
match, err := s.passHasher.VerifyPassword(req.Password, user.PasswordHash)
|
||||||
|
if err != nil || !match {
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
now := time.Now()
|
||||||
|
user.LastLoginAt = &now
|
||||||
|
if s.permissionService != nil {
|
||||||
|
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
|
||||||
|
// Log error but don't fail the login
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.LoginResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
User: dto.NewUserDTO(user),
|
||||||
|
ExpiresIn: 3600,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAccessToken refreshes an access token
|
||||||
|
func (s *AuthService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, error) {
|
||||||
|
claims, err := s.jwtManager.VerifyRefreshToken(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.GetUserByID(ctx, mustParseObjectID(claims.UserID))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestPasswordReset initiates password reset flow
|
||||||
|
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
|
||||||
|
user, err := s.userRepo.GetUserByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
// Don't reveal if email exists (security best practice)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := auth.GenerateRandomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
recovery := &entities.AccountRecovery{
|
||||||
|
UserID: user.ID,
|
||||||
|
Token: token,
|
||||||
|
Type: "password_reset",
|
||||||
|
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save recovery token
|
||||||
|
// This would need AccountRecoveryRepository implementation
|
||||||
|
_ = recovery
|
||||||
|
|
||||||
|
// In production: send email with reset link containing token
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustParseObjectID parses a string to ObjectID, panics on error
|
||||||
|
func mustParseObjectID(id string) bson.ObjectID {
|
||||||
|
objID, _ := bson.ObjectIDFromHex(id)
|
||||||
|
return objID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProviders returns all active OAuth/OIDC providers.
|
||||||
|
func (s *AuthService) ListProviders(ctx context.Context) ([]*dto.AuthProviderDTO, error) {
|
||||||
|
flags, err := s.GetFeatureFlags(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !flags.ProviderLoginEnabled {
|
||||||
|
return []*dto.AuthProviderDTO{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.providerRepo == nil {
|
||||||
|
return []*dto.AuthProviderDTO{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
providers, err := s.providerRepo.GetAllProviders(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*dto.AuthProviderDTO, 0, len(providers))
|
||||||
|
for _, provider := range providers {
|
||||||
|
result = append(result, dto.NewAuthProviderDTO(provider))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeatureFlags returns current app-wide feature flags.
|
||||||
|
func (s *AuthService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) {
|
||||||
|
if s.featureFlagRepo == nil {
|
||||||
|
return dto.NewFeatureFlagsDTO(nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := s.featureFlagRepo.GetFeatureFlags(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewFeatureFlagsDTO(flags), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProvider stores a new OAuth/OIDC provider.
|
||||||
|
func (s *AuthService) CreateProvider(ctx context.Context, req *dto.CreateAuthProviderRequest) (*dto.AuthProviderDTO, error) {
|
||||||
|
if s.providerRepo == nil || s.encryptor == nil {
|
||||||
|
return nil, errors.New("provider configuration unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
clientSecret := strings.TrimSpace(req.ClientSecret)
|
||||||
|
authorizationURL := strings.TrimSpace(req.AuthorizationURL)
|
||||||
|
tokenURL := strings.TrimSpace(req.TokenURL)
|
||||||
|
if name == "" || clientID == "" || clientSecret == "" || authorizationURL == "" || tokenURL == "" {
|
||||||
|
return nil, errors.New("missing required provider fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptedSecret, err := s.encryptor.Encrypt(clientSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &entities.AuthProvider{
|
||||||
|
Name: name,
|
||||||
|
Type: providerType,
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecret: encryptedSecret,
|
||||||
|
AuthorizationURL: authorizationURL,
|
||||||
|
TokenURL: tokenURL,
|
||||||
|
UserInfoURL: strings.TrimSpace(req.UserInfoURL),
|
||||||
|
Scopes: normalizeScopes(req.Scopes, providerType),
|
||||||
|
IDTokenClaim: strings.TrimSpace(req.IDTokenClaim),
|
||||||
|
IsActive: req.IsActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.providerRepo.CreateProvider(ctx, provider); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewAuthProviderDTO(provider), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildProviderAuthorizationURL constructs a provider authorization URL.
|
||||||
|
func (s *AuthService) BuildProviderAuthorizationURL(ctx context.Context, providerID bson.ObjectID, redirectURI, state string) (string, error) {
|
||||||
|
flags, err := s.GetFeatureFlags(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !flags.ProviderLoginEnabled {
|
||||||
|
return "", errors.New("provider login is currently disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, secret, err := s.getProviderConfig(ctx, providerID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
config := oauth2.Config{
|
||||||
|
ClientID: provider.ClientID,
|
||||||
|
ClientSecret: secret,
|
||||||
|
RedirectURL: redirectURI,
|
||||||
|
Scopes: normalizeScopes(provider.Scopes, provider.Type),
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: provider.AuthorizationURL,
|
||||||
|
TokenURL: provider.TokenURL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.AuthCodeURL(state, oauth2.AccessTypeOffline), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteProviderLogin exchanges an auth code and creates a user session.
|
||||||
|
func (s *AuthService) CompleteProviderLogin(ctx context.Context, providerID bson.ObjectID, code, redirectURI string) (*dto.LoginResponse, error) {
|
||||||
|
if s.providerRepo == nil || s.linkRepo == nil {
|
||||||
|
return nil, errors.New("provider login unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := s.GetFeatureFlags(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !flags.ProviderLoginEnabled {
|
||||||
|
return nil, errors.New("provider login is currently disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, secret, err := s.getProviderConfig(ctx, providerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
config := oauth2.Config{
|
||||||
|
ClientID: provider.ClientID,
|
||||||
|
ClientSecret: secret,
|
||||||
|
RedirectURL: redirectURI,
|
||||||
|
Scopes: normalizeScopes(provider.Scopes, provider.Type),
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: provider.AuthorizationURL,
|
||||||
|
TokenURL: provider.TokenURL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := config.Exchange(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := s.fetchProviderProfile(ctx, provider, token.AccessToken, token.Extra(provider.IDTokenClaim))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.findOrCreateOAuthUser(ctx, provider, profile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.LoginResponse{AccessToken: accessToken, RefreshToken: refreshToken, User: dto.NewUserDTO(user), ExpiresIn: 3600}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type providerProfile struct {
|
||||||
|
ProviderUserID string
|
||||||
|
Email string
|
||||||
|
Username string
|
||||||
|
FirstName string
|
||||||
|
LastName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) getProviderConfig(ctx context.Context, providerID bson.ObjectID) (*entities.AuthProvider, string, error) {
|
||||||
|
provider, err := s.providerRepo.GetProviderByID(ctx, providerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if !provider.IsActive {
|
||||||
|
return nil, "", errors.New("provider is inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
secret, err := s.encryptor.Decrypt(provider.ClientSecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider, secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) fetchProviderProfile(ctx context.Context, provider *entities.AuthProvider, accessToken string, rawIDToken any) (*providerProfile, error) {
|
||||||
|
payload := map[string]any{}
|
||||||
|
|
||||||
|
if provider.UserInfoURL != "" {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, provider.UserInfoURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("provider userinfo request failed: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if idToken, ok := rawIDToken.(string); ok && idToken != "" {
|
||||||
|
payload = decodeJWTWithoutVerify(idToken)
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("provider must define userinfo_url or return id_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := &providerProfile{
|
||||||
|
ProviderUserID: firstNonEmpty(asString(payload["sub"]), asString(payload["id"]), asString(payload["user_id"])),
|
||||||
|
Email: strings.ToLower(strings.TrimSpace(firstNonEmpty(asString(payload["email"]), asString(payload["upn"])))),
|
||||||
|
Username: firstNonEmpty(asString(payload["preferred_username"]), asString(payload["login"]), asString(payload["name"])),
|
||||||
|
FirstName: firstNonEmpty(asString(payload["given_name"]), asString(payload["first_name"])),
|
||||||
|
LastName: firstNonEmpty(asString(payload["family_name"]), asString(payload["last_name"])),
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.ProviderUserID == "" {
|
||||||
|
return nil, errors.New("provider user info missing subject identifier")
|
||||||
|
}
|
||||||
|
if profile.Email == "" {
|
||||||
|
profile.Email = fmt.Sprintf("%s@%s.oauth.local", sanitizeUsername(profile.ProviderUserID), sanitizeUsername(provider.Name))
|
||||||
|
}
|
||||||
|
if profile.Username == "" {
|
||||||
|
profile.Username = strings.Split(profile.Email, "@")[0]
|
||||||
|
}
|
||||||
|
profile.Username = sanitizeUsername(profile.Username)
|
||||||
|
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider *entities.AuthProvider, profile *providerProfile) (*entities.User, error) {
|
||||||
|
if link, err := s.linkRepo.GetLinkByProviderUserID(ctx, provider.ID, profile.ProviderUserID); err == nil {
|
||||||
|
return s.userRepo.GetUserByID(ctx, link.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.GetUserByEmail(ctx, profile.Email)
|
||||||
|
if err != nil {
|
||||||
|
username, err := s.generateUniqueUsername(ctx, profile.Username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user = &entities.User{Email: profile.Email, Username: username, PasswordHash: "", FirstName: profile.FirstName, LastName: profile.LastName, IsActive: true, EmailVerified: true}
|
||||||
|
if err := s.userRepo.CreateUser(ctx, user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.linkRepo.GetLink(ctx, user.ID, provider.ID); err != nil {
|
||||||
|
if err := s.linkRepo.CreateLink(ctx, &entities.UserProviderLink{UserID: user.ID, ProviderID: provider.ID, ProviderUserID: profile.ProviderUserID, Email: profile.Email}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) generateUniqueUsername(ctx context.Context, base string) (string, error) {
|
||||||
|
base = sanitizeUsername(base)
|
||||||
|
candidates := []string{base}
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
token, err := auth.GenerateRandomToken(2)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
candidates = append(candidates, fmt.Sprintf("%s-%s", base, token[:4]))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if _, err := s.userRepo.GetUserByUsername(ctx, candidate); err != nil {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%d", base, time.Now().Unix()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeScopes(scopes []string, providerType string) []string {
|
||||||
|
if len(scopes) == 0 {
|
||||||
|
if providerType == "oidc" {
|
||||||
|
return []string{"openid", "profile", "email"}
|
||||||
|
}
|
||||||
|
return []string{"profile", "email"}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(scopes))
|
||||||
|
for _, scope := range scopes {
|
||||||
|
scope = strings.TrimSpace(scope)
|
||||||
|
if scope != "" {
|
||||||
|
result = append(result, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJWTWithoutVerify(token string) map[string]any {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := map[string]any{}
|
||||||
|
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
func asString(value any) string {
|
||||||
|
if str, ok := value.(string); ok {
|
||||||
|
return strings.TrimSpace(str)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeUsername(value string) string {
|
||||||
|
cleaned := regexp.MustCompile(`[^a-zA-Z0-9_-]+`).ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), "-")
|
||||||
|
cleaned = strings.Trim(cleaned, "-")
|
||||||
|
if cleaned == "" {
|
||||||
|
return "user"
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
283
backend/internal/application/services/category_service.go
Normal file
283
backend/internal/application/services/category_service.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/application/dto"
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
"github.com/noteapp/backend/internal/domain/repositories"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CategoryService handles category operations
|
||||||
|
type CategoryService struct {
|
||||||
|
categoryRepo repositories.CategoryRepository
|
||||||
|
membershipRepo repositories.MembershipRepository
|
||||||
|
noteRepo repositories.NoteRepository
|
||||||
|
permissionService *PermissionService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCategoryService creates a new category service
|
||||||
|
func NewCategoryService(
|
||||||
|
categoryRepo repositories.CategoryRepository,
|
||||||
|
membershipRepo repositories.MembershipRepository,
|
||||||
|
noteRepo repositories.NoteRepository,
|
||||||
|
permissionService *PermissionService,
|
||||||
|
) *CategoryService {
|
||||||
|
return &CategoryService{
|
||||||
|
categoryRepo: categoryRepo,
|
||||||
|
membershipRepo: membershipRepo,
|
||||||
|
noteRepo: noteRepo,
|
||||||
|
permissionService: permissionService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCategory creates a new category
|
||||||
|
func (s *CategoryService) CreateCategory(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateCategoryRequest) (*dto.CategoryDTO, error) {
|
||||||
|
// Verify user has access to space
|
||||||
|
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
_ = membership
|
||||||
|
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.create")
|
||||||
|
if permErr != nil {
|
||||||
|
return nil, permErr
|
||||||
|
}
|
||||||
|
if !hasPermission {
|
||||||
|
return nil, errors.New("insufficient permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentID *bson.ObjectID
|
||||||
|
if req.ParentID != nil {
|
||||||
|
id, _ := bson.ObjectIDFromHex(*req.ParentID)
|
||||||
|
parentID = &id
|
||||||
|
|
||||||
|
// Verify parent category exists and belongs to same space
|
||||||
|
parent, err := s.categoryRepo.GetCategoryByID(ctx, id)
|
||||||
|
if err != nil || parent.SpaceID != spaceID {
|
||||||
|
return nil, errors.New("invalid parent category")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next order value
|
||||||
|
categories, err := s.categoryRepo.GetCategoriesBySpaceID(ctx, spaceID)
|
||||||
|
order := len(categories)
|
||||||
|
|
||||||
|
category := &entities.Category{
|
||||||
|
SpaceID: spaceID,
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
ParentID: parentID,
|
||||||
|
Icon: req.Icon,
|
||||||
|
Order: order,
|
||||||
|
CreatedBy: userID,
|
||||||
|
UpdatedBy: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.categoryRepo.CreateCategory(ctx, category); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewCategoryDTO(category), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCategoryTree retrieves the full tree structure for a space
|
||||||
|
func (s *CategoryService) GetCategoryTree(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.CategoryTreeDTO, error) {
|
||||||
|
// Verify user has access to space
|
||||||
|
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get root categories
|
||||||
|
categories, err := s.categoryRepo.GetRootCategories(ctx, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var trees []*dto.CategoryTreeDTO
|
||||||
|
for _, category := range categories {
|
||||||
|
tree, err := s.buildCategoryTree(ctx, category, spaceID)
|
||||||
|
if err == nil {
|
||||||
|
trees = append(trees, tree)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trees, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCategoryTree recursively builds a category tree
|
||||||
|
func (s *CategoryService) buildCategoryTree(ctx context.Context, category *entities.Category, spaceID bson.ObjectID) (*dto.CategoryTreeDTO, error) {
|
||||||
|
tree := &dto.CategoryTreeDTO{
|
||||||
|
CategoryDTO: dto.NewCategoryDTO(category),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get subcategories
|
||||||
|
subcategories, err := s.categoryRepo.GetSubcategories(ctx, category.ID)
|
||||||
|
if err == nil {
|
||||||
|
for _, subcat := range subcategories {
|
||||||
|
subtree, err := s.buildCategoryTree(ctx, subcat, spaceID)
|
||||||
|
if err == nil {
|
||||||
|
tree.Subcategories = append(tree.Subcategories, subtree)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get notes in this category
|
||||||
|
notes, err := s.noteRepo.GetNotesByCategory(ctx, spaceID, category.ID)
|
||||||
|
if err == nil {
|
||||||
|
for _, note := range notes {
|
||||||
|
tree.Notes = append(tree.Notes, dto.NewNoteListItemDTO(note))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCategory updates a category
|
||||||
|
func (s *CategoryService) UpdateCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, req *dto.UpdateCategoryRequest) (*dto.CategoryDTO, error) {
|
||||||
|
// Verify user has access to space
|
||||||
|
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
_ = membership
|
||||||
|
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.edit")
|
||||||
|
if permErr != nil {
|
||||||
|
return nil, permErr
|
||||||
|
}
|
||||||
|
if !hasPermission {
|
||||||
|
return nil, errors.New("insufficient permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify category belongs to this space
|
||||||
|
if category.SpaceID != spaceID {
|
||||||
|
return nil, errors.New("category not found in this space")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != "" {
|
||||||
|
category.Name = req.Name
|
||||||
|
}
|
||||||
|
if req.Description != "" {
|
||||||
|
category.Description = req.Description
|
||||||
|
}
|
||||||
|
if req.Icon != "" {
|
||||||
|
category.Icon = req.Icon
|
||||||
|
}
|
||||||
|
|
||||||
|
category.UpdatedBy = userID
|
||||||
|
|
||||||
|
if err := s.categoryRepo.UpdateCategory(ctx, category); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewCategoryDTO(category), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCategory deletes a category (and optionally move notes)
|
||||||
|
func (s *CategoryService) DeleteCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, moveNotesTo *string) error {
|
||||||
|
// Verify user has access to space
|
||||||
|
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
_ = membership
|
||||||
|
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.delete")
|
||||||
|
if permErr != nil {
|
||||||
|
return permErr
|
||||||
|
}
|
||||||
|
if !hasPermission {
|
||||||
|
return errors.New("insufficient permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify category belongs to this space
|
||||||
|
if category.SpaceID != spaceID {
|
||||||
|
return errors.New("category not found in this space")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle notes in this category
|
||||||
|
notes, err := s.noteRepo.GetNotesByCategory(ctx, spaceID, categoryID)
|
||||||
|
if err == nil {
|
||||||
|
for _, note := range notes {
|
||||||
|
if moveNotesTo != nil {
|
||||||
|
targetID, _ := bson.ObjectIDFromHex(*moveNotesTo)
|
||||||
|
note.CategoryID = &targetID
|
||||||
|
s.noteRepo.UpdateNote(ctx, note)
|
||||||
|
} else {
|
||||||
|
// Move to root (no category)
|
||||||
|
note.CategoryID = nil
|
||||||
|
s.noteRepo.UpdateNote(ctx, note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.categoryRepo.DeleteCategory(ctx, categoryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveCategory moves a category to a new parent
|
||||||
|
func (s *CategoryService) MoveCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, newParentID *string) (*dto.CategoryDTO, error) {
|
||||||
|
// Verify user has access to space
|
||||||
|
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
_ = membership
|
||||||
|
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.edit")
|
||||||
|
if permErr != nil {
|
||||||
|
return nil, permErr
|
||||||
|
}
|
||||||
|
if !hasPermission {
|
||||||
|
return nil, errors.New("insufficient permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify category belongs to this space
|
||||||
|
if category.SpaceID != spaceID {
|
||||||
|
return nil, errors.New("category not found in this space")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate new parent
|
||||||
|
if newParentID != nil {
|
||||||
|
parentID, _ := bson.ObjectIDFromHex(*newParentID)
|
||||||
|
parent, err := s.categoryRepo.GetCategoryByID(ctx, parentID)
|
||||||
|
if err != nil || parent.SpaceID != spaceID {
|
||||||
|
return nil, errors.New("invalid parent category")
|
||||||
|
}
|
||||||
|
category.ParentID = &parentID
|
||||||
|
} else {
|
||||||
|
category.ParentID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
category.UpdatedBy = userID
|
||||||
|
category.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if err := s.categoryRepo.UpdateCategory(ctx, category); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewCategoryDTO(category), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CategoryService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
|
||||||
|
if s.permissionService == nil {
|
||||||
|
return false, errors.New("permission service unavailable")
|
||||||
|
}
|
||||||
|
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
|
||||||
|
}
|
||||||
427
backend/internal/application/services/note_service.go
Normal file
427
backend/internal/application/services/note_service.go
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/application/dto"
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
"github.com/noteapp/backend/internal/domain/repositories"
|
||||||
|
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NoteService handles note operations
|
||||||
|
type NoteService struct {
|
||||||
|
noteRepo repositories.NoteRepository
|
||||||
|
categoryRepo repositories.CategoryRepository
|
||||||
|
membershipRepo repositories.MembershipRepository
|
||||||
|
revisionRepo repositories.NoteRevisionRepository
|
||||||
|
spaceRepo repositories.SpaceRepository
|
||||||
|
permissionService *PermissionService
|
||||||
|
passwordHasher *security.PasswordHasher
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNoteService creates a new note service
|
||||||
|
func NewNoteService(
|
||||||
|
noteRepo repositories.NoteRepository,
|
||||||
|
categoryRepo repositories.CategoryRepository,
|
||||||
|
membershipRepo repositories.MembershipRepository,
|
||||||
|
revisionRepo repositories.NoteRevisionRepository,
|
||||||
|
spaceRepo repositories.SpaceRepository,
|
||||||
|
permissionService *PermissionService,
|
||||||
|
passwordHasher *security.PasswordHasher,
|
||||||
|
) *NoteService {
|
||||||
|
return &NoteService{
|
||||||
|
noteRepo: noteRepo,
|
||||||
|
categoryRepo: categoryRepo,
|
||||||
|
membershipRepo: membershipRepo,
|
||||||
|
revisionRepo: revisionRepo,
|
||||||
|
spaceRepo: spaceRepo,
|
||||||
|
permissionService: permissionService,
|
||||||
|
passwordHasher: passwordHasher,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NoteService) toDisplayNoteDTO(note *entities.Note) *dto.NoteDTO {
|
||||||
|
noteDTO := dto.NewNoteDTO(note)
|
||||||
|
if note.IsPasswordProtected {
|
||||||
|
noteDTO.Content = ""
|
||||||
|
}
|
||||||
|
return noteDTO
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNote creates a new note
|
||||||
|
func (s *NoteService) CreateNote(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateNoteRequest) (*dto.NoteDTO, error) {
|
||||||
|
// Verify user has access to space
|
||||||
|
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
_ = membership
|
||||||
|
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.create")
|
||||||
|
if permErr != nil {
|
||||||
|
return nil, permErr
|
||||||
|
}
|
||||||
|
if !hasPermission {
|
||||||
|
return nil, errors.New("insufficient permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
var categoryID *bson.ObjectID
|
||||||
|
if req.CategoryID != nil {
|
||||||
|
id, _ := bson.ObjectIDFromHex(*req.CategoryID)
|
||||||
|
categoryID = &id
|
||||||
|
}
|
||||||
|
|
||||||
|
note := &entities.Note{
|
||||||
|
SpaceID: spaceID,
|
||||||
|
CategoryID: categoryID,
|
||||||
|
Title: req.Title,
|
||||||
|
Description: req.Description,
|
||||||
|
Content: req.Content,
|
||||||
|
Tags: req.Tags,
|
||||||
|
IsPinned: req.IsPinned,
|
||||||
|
IsFavorite: req.IsFavorite,
|
||||||
|
IsPublic: req.IsPublic,
|
||||||
|
CreatedBy: userID,
|
||||||
|
UpdatedBy: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
notePassword := strings.TrimSpace(req.NotePassword)
|
||||||
|
if notePassword != "" {
|
||||||
|
if len(notePassword) < 4 {
|
||||||
|
return nil, errors.New("note password must be at least 4 characters")
|
||||||
|
}
|
||||||
|
if s.passwordHasher == nil {
|
||||||
|
return nil, errors.New("password hasher unavailable")
|
||||||
|
}
|
||||||
|
hash, err := s.passwordHasher.HashPassword(notePassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
note.PasswordHash = hash
|
||||||
|
note.IsPasswordProtected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.noteRepo.CreateNote(ctx, note); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewNoteDTO(note), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNote retrieves a note (with space authorization check)
|
||||||
|
func (s *NoteService) GetNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID) (*dto.NoteDTO, error) {
|
||||||
|
// Verify user has access to space
|
||||||
|
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify note belongs to this space
|
||||||
|
if note.SpaceID != spaceID {
|
||||||
|
return nil, errors.New("note not found in this space")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update viewed time
|
||||||
|
now := time.Now()
|
||||||
|
note.ViewedAt = &now
|
||||||
|
_ = s.noteRepo.UpdateNote(ctx, note)
|
||||||
|
|
||||||
|
return s.toDisplayNoteDTO(note), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotesBySpace retrieves notes in a space
|
||||||
|
func (s *NoteService) GetNotesBySpace(ctx context.Context, spaceID, userID bson.ObjectID, skip, limit int) ([]*dto.NoteDTO, error) {
|
||||||
|
// Verify user has access to space
|
||||||
|
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
notes, err := s.noteRepo.GetNotesBySpaceID(ctx, spaceID, skip, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var noteDTOs []*dto.NoteDTO
|
||||||
|
for _, note := range notes {
|
||||||
|
noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note))
|
||||||
|
}
|
||||||
|
|
||||||
|
return noteDTOs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicNotesBySpace returns notes for a public space without requiring auth
|
||||||
|
func (s *NoteService) GetPublicNotesBySpace(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*dto.NoteDTO, error) {
|
||||||
|
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("space not found")
|
||||||
|
}
|
||||||
|
if !space.IsPublic {
|
||||||
|
return nil, errors.New("space is not public")
|
||||||
|
}
|
||||||
|
|
||||||
|
notes, err := s.noteRepo.GetPublicNotesBySpaceID(ctx, spaceID, skip, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var noteDTOs []*dto.NoteDTO
|
||||||
|
for _, note := range notes {
|
||||||
|
noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note))
|
||||||
|
}
|
||||||
|
return noteDTOs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicNoteBySpaceAndID returns a single public note in a public space without requiring auth
|
||||||
|
func (s *NoteService) GetPublicNoteBySpaceAndID(ctx context.Context, spaceID, noteID bson.ObjectID) (*dto.NoteDTO, error) {
|
||||||
|
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("space not found")
|
||||||
|
}
|
||||||
|
if !space.IsPublic {
|
||||||
|
return nil, errors.New("space is not public")
|
||||||
|
}
|
||||||
|
|
||||||
|
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("note not found")
|
||||||
|
}
|
||||||
|
if note.SpaceID != spaceID {
|
||||||
|
return nil, errors.New("note not found")
|
||||||
|
}
|
||||||
|
if !note.IsPublic {
|
||||||
|
return nil, errors.New("note is not public")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.toDisplayNoteDTO(note), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchNotes performs full-text search on notes in a space
|
||||||
|
func (s *NoteService) SearchNotes(ctx context.Context, spaceID, userID bson.ObjectID, query string) ([]*dto.NoteDTO, error) {
|
||||||
|
// Verify user has access to space
|
||||||
|
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
notes, err := s.noteRepo.SearchNotes(ctx, spaceID, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var noteDTOs []*dto.NoteDTO
|
||||||
|
for _, note := range notes {
|
||||||
|
noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note))
|
||||||
|
}
|
||||||
|
|
||||||
|
return noteDTOs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNote updates a note
|
||||||
|
func (s *NoteService) UpdateNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID, req *dto.UpdateNoteRequest) (*dto.NoteDTO, error) {
|
||||||
|
// Verify user has access to space
|
||||||
|
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
_ = membership
|
||||||
|
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.edit")
|
||||||
|
if permErr != nil {
|
||||||
|
return nil, permErr
|
||||||
|
}
|
||||||
|
if !hasPermission {
|
||||||
|
return nil, errors.New("insufficient permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify note belongs to this space
|
||||||
|
if note.SpaceID != spaceID {
|
||||||
|
return nil, errors.New("note not found in this space")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create revision before updating
|
||||||
|
if s.revisionRepo != nil {
|
||||||
|
revision := &entities.NoteRevision{
|
||||||
|
NoteID: note.ID,
|
||||||
|
SpaceID: spaceID,
|
||||||
|
Title: note.Title,
|
||||||
|
Content: note.Content,
|
||||||
|
ChangedBy: userID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
_ = s.revisionRepo.CreateRevision(ctx, revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields
|
||||||
|
if req.Title != "" {
|
||||||
|
note.Title = req.Title
|
||||||
|
}
|
||||||
|
if req.Content != "" {
|
||||||
|
note.Content = req.Content
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
note.Description = *req.Description
|
||||||
|
}
|
||||||
|
if req.Tags != nil {
|
||||||
|
note.Tags = req.Tags
|
||||||
|
}
|
||||||
|
if req.CategoryID != nil {
|
||||||
|
id, _ := bson.ObjectIDFromHex(*req.CategoryID)
|
||||||
|
note.CategoryID = &id
|
||||||
|
}
|
||||||
|
if req.IsPinned != nil {
|
||||||
|
note.IsPinned = *req.IsPinned
|
||||||
|
}
|
||||||
|
if req.IsFavorite != nil {
|
||||||
|
note.IsFavorite = *req.IsFavorite
|
||||||
|
}
|
||||||
|
if req.IsPublic != nil {
|
||||||
|
note.IsPublic = *req.IsPublic
|
||||||
|
}
|
||||||
|
if req.NotePassword != nil {
|
||||||
|
if s.passwordHasher == nil {
|
||||||
|
return nil, errors.New("password hasher unavailable")
|
||||||
|
}
|
||||||
|
notePassword := strings.TrimSpace(*req.NotePassword)
|
||||||
|
if notePassword == "" {
|
||||||
|
note.PasswordHash = ""
|
||||||
|
note.IsPasswordProtected = false
|
||||||
|
} else {
|
||||||
|
if len(notePassword) < 4 {
|
||||||
|
return nil, errors.New("note password must be at least 4 characters")
|
||||||
|
}
|
||||||
|
hash, hashErr := s.passwordHasher.HashPassword(notePassword)
|
||||||
|
if hashErr != nil {
|
||||||
|
return nil, hashErr
|
||||||
|
}
|
||||||
|
note.PasswordHash = hash
|
||||||
|
note.IsPasswordProtected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
note.UpdatedBy = userID
|
||||||
|
|
||||||
|
if err := s.noteRepo.UpdateNote(ctx, note); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewNoteDTO(note), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockNote verifies a protected note password and returns full note content for authenticated users.
|
||||||
|
func (s *NoteService) UnlockNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID, password string) (*dto.NoteDTO, error) {
|
||||||
|
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("note not found")
|
||||||
|
}
|
||||||
|
if note.SpaceID != spaceID {
|
||||||
|
return nil, errors.New("note not found in this space")
|
||||||
|
}
|
||||||
|
if !note.IsPasswordProtected {
|
||||||
|
return dto.NewNoteDTO(note), nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(password) == "" {
|
||||||
|
return nil, errors.New("password is required")
|
||||||
|
}
|
||||||
|
if s.passwordHasher == nil {
|
||||||
|
return nil, errors.New("password hasher unavailable")
|
||||||
|
}
|
||||||
|
matched, verifyErr := s.passwordHasher.VerifyPassword(password, note.PasswordHash)
|
||||||
|
if verifyErr != nil || !matched {
|
||||||
|
return nil, errors.New("invalid note password")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
note.ViewedAt = &now
|
||||||
|
_ = s.noteRepo.UpdateNote(ctx, note)
|
||||||
|
|
||||||
|
return dto.NewNoteDTO(note), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockPublicNote verifies a protected public note password and returns full note content.
|
||||||
|
func (s *NoteService) UnlockPublicNote(ctx context.Context, spaceID, noteID bson.ObjectID, password string) (*dto.NoteDTO, error) {
|
||||||
|
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("space not found")
|
||||||
|
}
|
||||||
|
if !space.IsPublic {
|
||||||
|
return nil, errors.New("space is not public")
|
||||||
|
}
|
||||||
|
|
||||||
|
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("note not found")
|
||||||
|
}
|
||||||
|
if note.SpaceID != spaceID || !note.IsPublic {
|
||||||
|
return nil, errors.New("note not found")
|
||||||
|
}
|
||||||
|
if !note.IsPasswordProtected {
|
||||||
|
return dto.NewNoteDTO(note), nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(password) == "" {
|
||||||
|
return nil, errors.New("password is required")
|
||||||
|
}
|
||||||
|
if s.passwordHasher == nil {
|
||||||
|
return nil, errors.New("password hasher unavailable")
|
||||||
|
}
|
||||||
|
matched, verifyErr := s.passwordHasher.VerifyPassword(password, note.PasswordHash)
|
||||||
|
if verifyErr != nil || !matched {
|
||||||
|
return nil, errors.New("invalid note password")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
note.ViewedAt = &now
|
||||||
|
_ = s.noteRepo.UpdateNote(ctx, note)
|
||||||
|
|
||||||
|
return dto.NewNoteDTO(note), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNote deletes a note
|
||||||
|
func (s *NoteService) DeleteNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID) error {
|
||||||
|
// Verify user has access to space
|
||||||
|
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
_ = membership
|
||||||
|
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.delete")
|
||||||
|
if permErr != nil {
|
||||||
|
return permErr
|
||||||
|
}
|
||||||
|
if !hasPermission {
|
||||||
|
return errors.New("insufficient permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify note belongs to this space
|
||||||
|
if note.SpaceID != spaceID {
|
||||||
|
return errors.New("note not found in this space")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.noteRepo.DeleteNote(ctx, noteID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NoteService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
|
||||||
|
if s.permissionService == nil {
|
||||||
|
return false, errors.New("permission service unavailable")
|
||||||
|
}
|
||||||
|
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
|
||||||
|
}
|
||||||
174
backend/internal/application/services/permission_service.go
Normal file
174
backend/internal/application/services/permission_service.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
"github.com/noteapp/backend/internal/domain/repositories"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
const adminGroupName = "Admin"
|
||||||
|
|
||||||
|
// PermissionService resolves and checks user permissions.
|
||||||
|
type PermissionService struct {
|
||||||
|
userRepo repositories.UserRepository
|
||||||
|
groupRepo repositories.GroupRepository
|
||||||
|
membershipRepo repositories.MembershipRepository
|
||||||
|
spaceRepo repositories.SpaceRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPermissionService creates a permission service.
|
||||||
|
func NewPermissionService(
|
||||||
|
userRepo repositories.UserRepository,
|
||||||
|
groupRepo repositories.GroupRepository,
|
||||||
|
membershipRepo repositories.MembershipRepository,
|
||||||
|
spaceRepo repositories.SpaceRepository,
|
||||||
|
) *PermissionService {
|
||||||
|
return &PermissionService{
|
||||||
|
userRepo: userRepo,
|
||||||
|
groupRepo: groupRepo,
|
||||||
|
membershipRepo: membershipRepo,
|
||||||
|
spaceRepo: spaceRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAdminGroup ensures the built-in Admin group exists with full wildcard access.
|
||||||
|
func (s *PermissionService) EnsureAdminGroup(ctx context.Context) error {
|
||||||
|
adminGroup, err := s.groupRepo.GetGroupByName(ctx, adminGroupName)
|
||||||
|
if err != nil {
|
||||||
|
adminGroup = &entities.PermissionGroup{
|
||||||
|
Name: adminGroupName,
|
||||||
|
Description: "System group with full access",
|
||||||
|
Permissions: []string{"*"},
|
||||||
|
IsSystem: true,
|
||||||
|
}
|
||||||
|
if createErr := s.groupRepo.CreateGroup(ctx, adminGroup); createErr != nil {
|
||||||
|
return createErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserEffectivePermissions returns a deduplicated list of permissions for a user.
|
||||||
|
func (s *PermissionService) GetUserEffectivePermissions(ctx context.Context, user *entities.User) ([]string, error) {
|
||||||
|
granted := make(map[string]struct{})
|
||||||
|
|
||||||
|
groups, err := s.groupRepo.GetGroupsByIDs(ctx, user.GroupIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, group := range groups {
|
||||||
|
for _, permission := range group.Permissions {
|
||||||
|
normalized := entities.NormalizePermission(permission)
|
||||||
|
if normalized != "" {
|
||||||
|
granted[normalized] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(granted))
|
||||||
|
for permission := range granted {
|
||||||
|
result = append(result, permission)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserEffectivePermissions resolves and persists effective user permissions.
|
||||||
|
func (s *PermissionService) UpdateUserEffectivePermissions(ctx context.Context, user *entities.User) error {
|
||||||
|
permissions, err := s.GetUserEffectivePermissions(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.Permissions = permissions
|
||||||
|
return s.userRepo.UpdateUser(ctx, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserGroups assigns groups to a user and refreshes permissions.
|
||||||
|
func (s *PermissionService) SetUserGroups(ctx context.Context, userID bson.ObjectID, groupIDs []bson.ObjectID) (*entities.User, error) {
|
||||||
|
user, err := s.userRepo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(groupIDs) > 0 {
|
||||||
|
groups, err := s.groupRepo.GetGroupsByIDs(ctx, groupIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(groups) != len(groupIDs) {
|
||||||
|
return nil, errors.New("one or more groups not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.GroupIDs = dedupeObjectIDs(groupIDs)
|
||||||
|
if err := s.UpdateUserEffectivePermissions(ctx, user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserHasPermission checks if user has a concrete permission, supporting wildcards.
|
||||||
|
func (s *PermissionService) UserHasPermission(ctx context.Context, userID bson.ObjectID, permission string) (bool, error) {
|
||||||
|
user, err := s.userRepo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return s.UserEntityHasPermission(ctx, user, permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserEntityHasPermission checks permission from a loaded user entity.
|
||||||
|
func (s *PermissionService) UserEntityHasPermission(ctx context.Context, user *entities.User, permission string) (bool, error) {
|
||||||
|
permission = entities.NormalizePermission(permission)
|
||||||
|
if permission == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
granted, err := s.GetUserEffectivePermissions(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pattern := range granted {
|
||||||
|
if entities.PermissionMatches(pattern, permission) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasSpacePermission checks a space-scoped permission action, like note.create.
|
||||||
|
func (s *PermissionService) HasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
|
||||||
|
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
action = strings.Trim(strings.ToLower(action), ". ")
|
||||||
|
if action == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
permission := "space." + entities.SpacePermissionToken(space.Name) + "." + action
|
||||||
|
return s.UserHasPermission(ctx, userID, permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeObjectIDs(ids []bson.ObjectID) []bson.ObjectID {
|
||||||
|
seen := map[bson.ObjectID]struct{}{}
|
||||||
|
result := make([]bson.ObjectID, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[id]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
result = append(result, id)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
319
backend/internal/application/services/space_service.go
Normal file
319
backend/internal/application/services/space_service.go
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/application/dto"
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
"github.com/noteapp/backend/internal/domain/repositories"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SpaceService handles space operations
|
||||||
|
type SpaceService struct {
|
||||||
|
spaceRepo repositories.SpaceRepository
|
||||||
|
membershipRepo repositories.MembershipRepository
|
||||||
|
noteRepo repositories.NoteRepository
|
||||||
|
categoryRepo repositories.CategoryRepository
|
||||||
|
userRepo repositories.UserRepository
|
||||||
|
permissionService *PermissionService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSpaceService creates a new space service
|
||||||
|
func NewSpaceService(
|
||||||
|
spaceRepo repositories.SpaceRepository,
|
||||||
|
membershipRepo repositories.MembershipRepository,
|
||||||
|
noteRepo repositories.NoteRepository,
|
||||||
|
categoryRepo repositories.CategoryRepository,
|
||||||
|
userRepo repositories.UserRepository,
|
||||||
|
permissionService *PermissionService,
|
||||||
|
) *SpaceService {
|
||||||
|
return &SpaceService{
|
||||||
|
spaceRepo: spaceRepo,
|
||||||
|
membershipRepo: membershipRepo,
|
||||||
|
noteRepo: noteRepo,
|
||||||
|
categoryRepo: categoryRepo,
|
||||||
|
userRepo: userRepo,
|
||||||
|
permissionService: permissionService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicSpace returns a single publicly accessible space
|
||||||
|
func (s *SpaceService) GetPublicSpace(ctx context.Context, spaceID bson.ObjectID) (*dto.SpaceDTO, error) {
|
||||||
|
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !space.IsPublic {
|
||||||
|
return nil, errors.New("space is not public")
|
||||||
|
}
|
||||||
|
return dto.NewSpaceDTO(space), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicSpaces returns all publicly accessible spaces
|
||||||
|
func (s *SpaceService) GetPublicSpaces(ctx context.Context) ([]*dto.SpaceDTO, error) {
|
||||||
|
spaces, err := s.spaceRepo.GetPublicSpaces(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*dto.SpaceDTO, len(spaces))
|
||||||
|
for i, space := range spaces {
|
||||||
|
result[i] = dto.NewSpaceDTO(space)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSpace creates a new space owned by the user
|
||||||
|
func (s *SpaceService) CreateSpace(ctx context.Context, userID bson.ObjectID, req *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) {
|
||||||
|
if allowed, err := s.canCreateSpace(ctx, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !allowed {
|
||||||
|
return nil, errors.New("insufficient permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
space := &entities.Space{
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
Icon: req.Icon,
|
||||||
|
OwnerID: userID,
|
||||||
|
IsPublic: req.IsPublic,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.spaceRepo.CreateSpace(ctx, space); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user as initial member
|
||||||
|
membership := &entities.Membership{
|
||||||
|
UserID: userID,
|
||||||
|
SpaceID: space.ID,
|
||||||
|
JoinedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.membershipRepo.CreateMembership(ctx, membership); err != nil {
|
||||||
|
// Delete space if membership creation fails
|
||||||
|
s.spaceRepo.DeleteSpace(ctx, space.ID)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewSpaceDTO(space), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserSpaces retrieves all spaces for a user
|
||||||
|
func (s *SpaceService) GetUserSpaces(ctx context.Context, userID bson.ObjectID) ([]*dto.SpaceDTO, error) {
|
||||||
|
// Get all memberships for the user
|
||||||
|
memberships, err := s.membershipRepo.GetUserMemberships(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var spaceDTOs []*dto.SpaceDTO
|
||||||
|
for _, membership := range memberships {
|
||||||
|
space, err := s.spaceRepo.GetSpaceByID(ctx, membership.SpaceID)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip spaces that can't be loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceDTOs = append(spaceDTOs, dto.NewSpaceDTO(space))
|
||||||
|
}
|
||||||
|
|
||||||
|
return spaceDTOs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpaceByID gets a space by ID (with authorization check)
|
||||||
|
func (s *SpaceService) GetSpaceByID(ctx context.Context, spaceID, userID bson.ObjectID) (*dto.SpaceDTO, error) {
|
||||||
|
// Verify user has access to this space
|
||||||
|
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewSpaceDTO(space), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSpace updates a space (owner only)
|
||||||
|
func (s *SpaceService) UpdateSpace(ctx context.Context, spaceID, userID bson.ObjectID, updates *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) {
|
||||||
|
hasPermission, err := s.hasGlobalOrSpacePermission(ctx, userID, spaceID, "space.edit", "settings.edit")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !hasPermission {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
space.Name = updates.Name
|
||||||
|
space.Description = updates.Description
|
||||||
|
space.Icon = updates.Icon
|
||||||
|
space.IsPublic = updates.IsPublic
|
||||||
|
|
||||||
|
if err := s.spaceRepo.UpdateSpace(ctx, space); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewSpaceDTO(space), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSpace deletes a space (owner only)
|
||||||
|
func (s *SpaceService) DeleteSpace(ctx context.Context, spaceID, userID bson.ObjectID) error {
|
||||||
|
hasPermission, err := s.hasGlobalOrSpacePermission(ctx, userID, spaceID, "space.delete", "settings.delete")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !hasPermission {
|
||||||
|
return errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.noteRepo.DeleteNotesBySpaceID(ctx, spaceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.spaceRepo.DeleteSpace(ctx, spaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMember adds a member to a space.
|
||||||
|
func (s *SpaceService) AddMember(ctx context.Context, spaceID, userID, targetUserID bson.ObjectID) error {
|
||||||
|
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !hasPermission {
|
||||||
|
return errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create membership for target user
|
||||||
|
newMembership := &entities.Membership{
|
||||||
|
UserID: targetUserID,
|
||||||
|
SpaceID: spaceID,
|
||||||
|
JoinedAt: time.Now(),
|
||||||
|
InvitedBy: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.membershipRepo.CreateMembership(ctx, newMembership)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMember removes a member from a space (owner only)
|
||||||
|
func (s *SpaceService) RemoveMember(ctx context.Context, spaceID, userID, targetUserID bson.ObjectID) error {
|
||||||
|
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !hasPermission {
|
||||||
|
return errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target membership
|
||||||
|
targetMembership, err := s.membershipRepo.GetUserMembership(ctx, targetUserID, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.membershipRepo.DeleteMembership(ctx, targetMembership.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpaceMembers returns all space members (owner only)
|
||||||
|
func (s *SpaceService) GetSpaceMembers(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.SpaceMemberDTO, error) {
|
||||||
|
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.view")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !hasPermission {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
memberships, err := s.membershipRepo.GetSpaceMembers(ctx, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*dto.SpaceMemberDTO, 0, len(memberships))
|
||||||
|
for _, member := range memberships {
|
||||||
|
username := member.UserID.Hex()
|
||||||
|
if user, err := s.userRepo.GetUserByID(ctx, member.UserID); err == nil {
|
||||||
|
username = user.Username
|
||||||
|
}
|
||||||
|
result = append(result, &dto.SpaceMemberDTO{
|
||||||
|
UserID: member.UserID.Hex(),
|
||||||
|
Username: username,
|
||||||
|
JoinedAt: member.JoinedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAvailableUsers returns all users for member selection (owner only)
|
||||||
|
func (s *SpaceService) ListAvailableUsers(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.UserOptionDTO, error) {
|
||||||
|
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !hasPermission {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := s.userRepo.ListAllUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*dto.UserOptionDTO, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
result = append(result, &dto.UserOptionDTO{
|
||||||
|
ID: user.ID.Hex(),
|
||||||
|
Username: user.Username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SpaceService) canCreateSpace(ctx context.Context, userID bson.ObjectID) (bool, error) {
|
||||||
|
if s.permissionService == nil {
|
||||||
|
return false, errors.New("permission service unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPermission, err := s.permissionService.UserHasPermission(ctx, userID, "space.create")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return hasPermission, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SpaceService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
|
||||||
|
if s.permissionService == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SpaceService) hasGlobalOrSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, globalPermission, spaceAction string) (bool, error) {
|
||||||
|
if s.permissionService == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hasGlobalPermission, err := s.permissionService.UserHasPermission(ctx, userID, globalPermission)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if hasGlobalPermission {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, spaceAction)
|
||||||
|
}
|
||||||
51
backend/internal/domain/entities/auth.go
Normal file
51
backend/internal/domain/entities/auth.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package entities
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthProvider represents a configured OAuth/OIDC provider
|
||||||
|
type AuthProvider struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
Name string `bson:"name"`
|
||||||
|
Type string `bson:"type"` // "oidc", "oauth2"
|
||||||
|
ClientID string `bson:"client_id"`
|
||||||
|
ClientSecret string `bson:"client_secret"` // Encrypted in DB
|
||||||
|
AuthorizationURL string `bson:"authorization_url"`
|
||||||
|
TokenURL string `bson:"token_url"`
|
||||||
|
UserInfoURL string `bson:"userinfo_url"`
|
||||||
|
Scopes []string `bson:"scopes"`
|
||||||
|
IDTokenClaim string `bson:"id_token_claim,omitempty"`
|
||||||
|
IsActive bool `bson:"is_active"`
|
||||||
|
CreatedAt time.Time `bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginAttempt tracks login attempts for brute-force protection
|
||||||
|
type LoginAttempt struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
Email string `bson:"email"`
|
||||||
|
IPAddress string `bson:"ip_address"`
|
||||||
|
Success bool `bson:"success"`
|
||||||
|
Reason string `bson:"reason,omitempty"`
|
||||||
|
CreatedAt time.Time `bson:"created_at"`
|
||||||
|
ExpiresAt time.Time `bson:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeatureFlags controls app-wide behavior toggles.
|
||||||
|
type FeatureFlags struct {
|
||||||
|
RegistrationEnabled bool `bson:"registration_enabled"`
|
||||||
|
ProviderLoginEnabled bool `bson:"provider_login_enabled"`
|
||||||
|
PublicSharingEnabled bool `bson:"public_sharing_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDefaultFeatureFlags returns safe defaults for a new deployment.
|
||||||
|
func NewDefaultFeatureFlags() *FeatureFlags {
|
||||||
|
return &FeatureFlags{
|
||||||
|
RegistrationEnabled: true,
|
||||||
|
ProviderLoginEnabled: true,
|
||||||
|
PublicSharingEnabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/internal/domain/entities/note.go
Normal file
55
backend/internal/domain/entities/note.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package entities
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note represents a note within a space
|
||||||
|
type Note struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
SpaceID bson.ObjectID `bson:"space_id"`
|
||||||
|
CategoryID *bson.ObjectID `bson:"category_id,omitempty"`
|
||||||
|
Title string `bson:"title"`
|
||||||
|
Description string `bson:"description"`
|
||||||
|
Content string `bson:"content"`
|
||||||
|
PasswordHash string `bson:"password_hash,omitempty"`
|
||||||
|
Tags []string `bson:"tags"`
|
||||||
|
IsPinned bool `bson:"is_pinned"`
|
||||||
|
IsFavorite bool `bson:"is_favorite"`
|
||||||
|
IsPublic bool `bson:"is_public"`
|
||||||
|
IsPasswordProtected bool `bson:"is_password_protected"`
|
||||||
|
CreatedBy bson.ObjectID `bson:"created_by"`
|
||||||
|
UpdatedBy bson.ObjectID `bson:"updated_by"`
|
||||||
|
CreatedAt time.Time `bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `bson:"updated_at"`
|
||||||
|
ViewedAt *time.Time `bson:"viewed_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category represents a folder/category within a space
|
||||||
|
type Category struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
SpaceID bson.ObjectID `bson:"space_id"`
|
||||||
|
Name string `bson:"name"`
|
||||||
|
Description string `bson:"description,omitempty"`
|
||||||
|
ParentID *bson.ObjectID `bson:"parent_id,omitempty"`
|
||||||
|
Icon string `bson:"icon,omitempty"`
|
||||||
|
Order int `bson:"order"`
|
||||||
|
CreatedBy bson.ObjectID `bson:"created_by"`
|
||||||
|
UpdatedBy bson.ObjectID `bson:"updated_by"`
|
||||||
|
CreatedAt time.Time `bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteRevision represents a historical version of a note
|
||||||
|
type NoteRevision struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
NoteID bson.ObjectID `bson:"note_id"`
|
||||||
|
SpaceID bson.ObjectID `bson:"space_id"`
|
||||||
|
Title string `bson:"title"`
|
||||||
|
Content string `bson:"content"`
|
||||||
|
ChangedBy bson.ObjectID `bson:"changed_by"`
|
||||||
|
CreatedAt time.Time `bson:"created_at"`
|
||||||
|
ChangeRef string `bson:"change_ref,omitempty"`
|
||||||
|
}
|
||||||
83
backend/internal/domain/entities/permission_group.go
Normal file
83
backend/internal/domain/entities/permission_group.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package entities
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
var permissionTokenSanitizer = regexp.MustCompile(`[^a-z0-9_-]+`)
|
||||||
|
|
||||||
|
// PermissionGroup represents a named group of permissions.
|
||||||
|
type PermissionGroup struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
Name string `bson:"name"`
|
||||||
|
NameKey string `bson:"name_key"`
|
||||||
|
Description string `bson:"description,omitempty"`
|
||||||
|
Permissions []string `bson:"permissions"`
|
||||||
|
IsSystem bool `bson:"is_system"`
|
||||||
|
CreatedAt time.Time `bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizePermission lowercases and trims a permission string.
|
||||||
|
func NormalizePermission(permission string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(permission))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpacePermissionToken converts a space name to a dot-safe permission token.
|
||||||
|
func SpacePermissionToken(spaceName string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(spaceName))
|
||||||
|
normalized = strings.ReplaceAll(normalized, " ", "_")
|
||||||
|
normalized = permissionTokenSanitizer.ReplaceAllString(normalized, "_")
|
||||||
|
normalized = strings.Trim(normalized, "_")
|
||||||
|
if normalized == "" {
|
||||||
|
return "space"
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// PermissionMatches reports whether a wildcard pattern matches a concrete permission.
|
||||||
|
func PermissionMatches(pattern, permission string) bool {
|
||||||
|
pattern = NormalizePermission(pattern)
|
||||||
|
permission = NormalizePermission(permission)
|
||||||
|
|
||||||
|
if pattern == "" || permission == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if pattern == "*" || pattern == permission {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !strings.Contains(pattern, "*") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(pattern, "*")
|
||||||
|
remaining := permission
|
||||||
|
|
||||||
|
if parts[0] != "" {
|
||||||
|
if !strings.HasPrefix(remaining, parts[0]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
remaining = remaining[len(parts[0]):]
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 1; i < len(parts); i++ {
|
||||||
|
part := parts[i]
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.Index(remaining, part)
|
||||||
|
if idx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
remaining = remaining[idx+len(part):]
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts[len(parts)-1] != "" {
|
||||||
|
return strings.HasSuffix(permission, parts[len(parts)-1])
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
41
backend/internal/domain/entities/space.go
Normal file
41
backend/internal/domain/entities/space.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package entities
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Space represents a top-level container for notes and categories
|
||||||
|
type Space struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
Name string `bson:"name"`
|
||||||
|
Description string `bson:"description,omitempty"`
|
||||||
|
Icon string `bson:"icon,omitempty"`
|
||||||
|
OwnerID bson.ObjectID `bson:"owner_id"`
|
||||||
|
IsPublic bool `bson:"is_public"`
|
||||||
|
CreatedAt time.Time `bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Membership represents a user's membership in a space
|
||||||
|
type Membership struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
UserID bson.ObjectID `bson:"user_id"`
|
||||||
|
SpaceID bson.ObjectID `bson:"space_id"`
|
||||||
|
JoinedAt time.Time `bson:"joined_at"`
|
||||||
|
InvitedBy bson.ObjectID `bson:"invited_by,omitempty"`
|
||||||
|
InvitedAt *time.Time `bson:"invited_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpaceInvitation represents an invitation to join a space
|
||||||
|
type SpaceInvitation struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
SpaceID bson.ObjectID `bson:"space_id"`
|
||||||
|
Email string `bson:"email"`
|
||||||
|
Token string `bson:"token"`
|
||||||
|
CreatedBy bson.ObjectID `bson:"created_by"`
|
||||||
|
CreatedAt time.Time `bson:"created_at"`
|
||||||
|
ExpiresAt time.Time `bson:"expires_at"`
|
||||||
|
AcceptedAt *time.Time `bson:"accepted_at,omitempty"`
|
||||||
|
}
|
||||||
51
backend/internal/domain/entities/user.go
Normal file
51
backend/internal/domain/entities/user.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package entities
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User represents a system user
|
||||||
|
type User struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
Email string `bson:"email"`
|
||||||
|
Username string `bson:"username"`
|
||||||
|
PasswordHash string `bson:"password_hash"`
|
||||||
|
FirstName string `bson:"first_name"`
|
||||||
|
LastName string `bson:"last_name"`
|
||||||
|
Avatar string `bson:"avatar,omitempty"`
|
||||||
|
GroupIDs []bson.ObjectID `bson:"group_ids,omitempty"`
|
||||||
|
Permissions []string `bson:"permissions,omitempty"`
|
||||||
|
IsActive bool `bson:"is_active"`
|
||||||
|
EmailVerified bool `bson:"email_verified"`
|
||||||
|
CreatedAt time.Time `bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `bson:"updated_at"`
|
||||||
|
LastLoginAt *time.Time `bson:"last_login_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserProviderLink links external OAuth/OIDC providers to a user
|
||||||
|
type UserProviderLink struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
UserID bson.ObjectID `bson:"user_id"`
|
||||||
|
ProviderID bson.ObjectID `bson:"provider_id"`
|
||||||
|
ProviderUserID string `bson:"provider_user_id"`
|
||||||
|
Email string `bson:"email"`
|
||||||
|
ProfileData map[string]any `bson:"profile_data,omitempty"`
|
||||||
|
AccessToken string `bson:"access_token"` // Consider encrypting in production
|
||||||
|
RefreshToken string `bson:"refresh_token,omitempty"`
|
||||||
|
AccessTokenExp *time.Time `bson:"access_token_exp,omitempty"`
|
||||||
|
LinkedAt time.Time `bson:"linked_at"`
|
||||||
|
LastUsedAt *time.Time `bson:"last_used_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountRecovery represents account recovery tokens
|
||||||
|
type AccountRecovery struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
UserID bson.ObjectID `bson:"user_id"`
|
||||||
|
Token string `bson:"token"`
|
||||||
|
Type string `bson:"type"` // "password_reset", "email_verification"
|
||||||
|
ExpiresAt time.Time `bson:"expires_at"`
|
||||||
|
UsedAt *time.Time `bson:"used_at,omitempty"`
|
||||||
|
CreatedAt time.Time `bson:"created_at"`
|
||||||
|
}
|
||||||
40
backend/internal/domain/repositories/additional.go
Normal file
40
backend/internal/domain/repositories/additional.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccountRecoveryRepository defines account recovery operations
|
||||||
|
type AccountRecoveryRepository interface {
|
||||||
|
CreateRecovery(ctx context.Context, recovery *entities.AccountRecovery) error
|
||||||
|
GetRecoveryByToken(ctx context.Context, token string) (*entities.AccountRecovery, error)
|
||||||
|
MarkRecoveryUsed(ctx context.Context, id bson.ObjectID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeatureFlagRepository defines app feature-flag operations.
|
||||||
|
type FeatureFlagRepository interface {
|
||||||
|
GetFeatureFlags(ctx context.Context) (*entities.FeatureFlags, error)
|
||||||
|
UpdateFeatureFlags(ctx context.Context, flags *entities.FeatureFlags) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional repository extensions
|
||||||
|
type (
|
||||||
|
// SpaceRepository extensions
|
||||||
|
SpaceRepositoryExt interface {
|
||||||
|
SpaceRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// MembershipRepository extensions
|
||||||
|
MembershipRepositoryExt interface {
|
||||||
|
MembershipRepository
|
||||||
|
GetUserMemberships(ctx context.Context, userID bson.ObjectID) ([]*entities.Membership, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteRepository extensions
|
||||||
|
NoteRepositoryExt interface {
|
||||||
|
NoteRepository
|
||||||
|
}
|
||||||
|
)
|
||||||
215
backend/internal/domain/repositories/interfaces.go
Normal file
215
backend/internal/domain/repositories/interfaces.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserRepository defines user operations
|
||||||
|
type UserRepository interface {
|
||||||
|
// CreateUser creates a new user
|
||||||
|
CreateUser(ctx context.Context, user *entities.User) error
|
||||||
|
|
||||||
|
// GetUserByID retrieves a user by ID
|
||||||
|
GetUserByID(ctx context.Context, id bson.ObjectID) (*entities.User, error)
|
||||||
|
|
||||||
|
// GetUserByEmail retrieves a user by email
|
||||||
|
GetUserByEmail(ctx context.Context, email string) (*entities.User, error)
|
||||||
|
|
||||||
|
// GetUserByUsername retrieves a user by username
|
||||||
|
GetUserByUsername(ctx context.Context, username string) (*entities.User, error)
|
||||||
|
|
||||||
|
// UpdateUser updates a user
|
||||||
|
UpdateUser(ctx context.Context, user *entities.User) error
|
||||||
|
|
||||||
|
// DeleteUser deletes a user
|
||||||
|
DeleteUser(ctx context.Context, id bson.ObjectID) error
|
||||||
|
|
||||||
|
// ListAllUsers retrieves all users (admin use)
|
||||||
|
ListAllUsers(ctx context.Context) ([]*entities.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupRepository defines permission group operations
|
||||||
|
type GroupRepository interface {
|
||||||
|
// CreateGroup creates a new permission group
|
||||||
|
CreateGroup(ctx context.Context, group *entities.PermissionGroup) error
|
||||||
|
|
||||||
|
// GetGroupByID retrieves a group by ID
|
||||||
|
GetGroupByID(ctx context.Context, id bson.ObjectID) (*entities.PermissionGroup, error)
|
||||||
|
|
||||||
|
// GetGroupByName retrieves a group by name
|
||||||
|
GetGroupByName(ctx context.Context, name string) (*entities.PermissionGroup, error)
|
||||||
|
|
||||||
|
// GetGroupsByIDs retrieves groups by IDs
|
||||||
|
GetGroupsByIDs(ctx context.Context, ids []bson.ObjectID) ([]*entities.PermissionGroup, error)
|
||||||
|
|
||||||
|
// ListGroups retrieves all groups
|
||||||
|
ListGroups(ctx context.Context) ([]*entities.PermissionGroup, error)
|
||||||
|
|
||||||
|
// UpdateGroup updates an existing group
|
||||||
|
UpdateGroup(ctx context.Context, group *entities.PermissionGroup) error
|
||||||
|
|
||||||
|
// DeleteGroup deletes a group
|
||||||
|
DeleteGroup(ctx context.Context, id bson.ObjectID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpaceRepository defines space operations
|
||||||
|
type SpaceRepository interface {
|
||||||
|
// CreateSpace creates a new space
|
||||||
|
CreateSpace(ctx context.Context, space *entities.Space) error
|
||||||
|
|
||||||
|
// GetSpaceByID retrieves a space by ID
|
||||||
|
GetSpaceByID(ctx context.Context, id bson.ObjectID) (*entities.Space, error)
|
||||||
|
|
||||||
|
// GetSpacesByUserID retrieves all spaces for a user
|
||||||
|
GetSpacesByUserID(ctx context.Context, userID bson.ObjectID) ([]*entities.Space, error)
|
||||||
|
|
||||||
|
// GetAllSpaces retrieves all spaces (admin use)
|
||||||
|
GetAllSpaces(ctx context.Context) ([]*entities.Space, error)
|
||||||
|
|
||||||
|
// GetPublicSpaces retrieves all spaces marked as public
|
||||||
|
GetPublicSpaces(ctx context.Context) ([]*entities.Space, error)
|
||||||
|
|
||||||
|
// UpdateSpace updates a space
|
||||||
|
UpdateSpace(ctx context.Context, space *entities.Space) error
|
||||||
|
|
||||||
|
// DeleteSpace deletes a space
|
||||||
|
DeleteSpace(ctx context.Context, id bson.ObjectID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MembershipRepository defines membership operations
|
||||||
|
type MembershipRepository interface {
|
||||||
|
// CreateMembership creates a new membership
|
||||||
|
CreateMembership(ctx context.Context, membership *entities.Membership) error
|
||||||
|
|
||||||
|
// GetMembershipByID retrieves a membership by ID
|
||||||
|
GetMembershipByID(ctx context.Context, id bson.ObjectID) (*entities.Membership, error)
|
||||||
|
|
||||||
|
// GetUserMembership retrieves a membership for a user in a space
|
||||||
|
GetUserMembership(ctx context.Context, userID, spaceID bson.ObjectID) (*entities.Membership, error)
|
||||||
|
|
||||||
|
// GetSpaceMembers retrieves all members in a space
|
||||||
|
GetSpaceMembers(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Membership, error)
|
||||||
|
|
||||||
|
// GetUserMemberships retrieves all memberships for a user
|
||||||
|
GetUserMemberships(ctx context.Context, userID bson.ObjectID) ([]*entities.Membership, error)
|
||||||
|
|
||||||
|
// UpdateMembership updates a membership
|
||||||
|
UpdateMembership(ctx context.Context, membership *entities.Membership) error
|
||||||
|
|
||||||
|
// DeleteMembership deletes a membership
|
||||||
|
DeleteMembership(ctx context.Context, id bson.ObjectID) error
|
||||||
|
|
||||||
|
// DeleteMembershipsBySpaceID deletes all memberships for a space
|
||||||
|
DeleteMembershipsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteRepository defines note operations
|
||||||
|
type NoteRepository interface {
|
||||||
|
// CreateNote creates a new note
|
||||||
|
CreateNote(ctx context.Context, note *entities.Note) error
|
||||||
|
|
||||||
|
// GetNoteByID retrieves a note by ID
|
||||||
|
GetNoteByID(ctx context.Context, id bson.ObjectID) (*entities.Note, error)
|
||||||
|
|
||||||
|
// GetNotesBySpaceID retrieves all notes in a space
|
||||||
|
GetNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error)
|
||||||
|
|
||||||
|
// GetPublicNotesBySpaceID retrieves public notes in a space
|
||||||
|
GetPublicNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error)
|
||||||
|
|
||||||
|
// GetNotesByCategory retrieves notes in a category
|
||||||
|
GetNotesByCategory(ctx context.Context, spaceID, categoryID bson.ObjectID) ([]*entities.Note, error)
|
||||||
|
|
||||||
|
// SearchNotes performs full-text search on notes
|
||||||
|
SearchNotes(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Note, error)
|
||||||
|
|
||||||
|
// UpdateNote updates a note
|
||||||
|
UpdateNote(ctx context.Context, note *entities.Note) error
|
||||||
|
|
||||||
|
// DeleteNote deletes a note
|
||||||
|
DeleteNote(ctx context.Context, id bson.ObjectID) error
|
||||||
|
|
||||||
|
// DeleteNotesBySpaceID deletes all notes in a space
|
||||||
|
DeleteNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryRepository defines category operations
|
||||||
|
type CategoryRepository interface {
|
||||||
|
// CreateCategory creates a new category
|
||||||
|
CreateCategory(ctx context.Context, category *entities.Category) error
|
||||||
|
|
||||||
|
// GetCategoryByID retrieves a category by ID
|
||||||
|
GetCategoryByID(ctx context.Context, id bson.ObjectID) (*entities.Category, error)
|
||||||
|
|
||||||
|
// GetCategoriesBySpaceID retrieves all categories in a space
|
||||||
|
GetCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error)
|
||||||
|
|
||||||
|
// GetRootCategories retrieves root level categories in a space
|
||||||
|
GetRootCategories(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error)
|
||||||
|
|
||||||
|
// GetSubcategories retrieves subcategories of a category
|
||||||
|
GetSubcategories(ctx context.Context, parentID bson.ObjectID) ([]*entities.Category, error)
|
||||||
|
|
||||||
|
// UpdateCategory updates a category
|
||||||
|
UpdateCategory(ctx context.Context, category *entities.Category) error
|
||||||
|
|
||||||
|
// DeleteCategory deletes a category
|
||||||
|
DeleteCategory(ctx context.Context, id bson.ObjectID) error
|
||||||
|
|
||||||
|
// DeleteCategoriesBySpaceID deletes all categories in a space
|
||||||
|
DeleteCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthProviderRepository defines auth provider operations
|
||||||
|
type AuthProviderRepository interface {
|
||||||
|
// CreateProvider creates a new auth provider
|
||||||
|
CreateProvider(ctx context.Context, provider *entities.AuthProvider) error
|
||||||
|
|
||||||
|
// GetProviderByID retrieves a provider by ID
|
||||||
|
GetProviderByID(ctx context.Context, id bson.ObjectID) (*entities.AuthProvider, error)
|
||||||
|
|
||||||
|
// GetAllProviders retrieves all active providers
|
||||||
|
GetAllProviders(ctx context.Context) ([]*entities.AuthProvider, error)
|
||||||
|
|
||||||
|
// UpdateProvider updates a provider
|
||||||
|
UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error
|
||||||
|
|
||||||
|
// DeleteProvider deletes a provider
|
||||||
|
DeleteProvider(ctx context.Context, id bson.ObjectID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserProviderLinkRepository defines user provider link operations
|
||||||
|
type UserProviderLinkRepository interface {
|
||||||
|
// CreateLink creates a new user provider link
|
||||||
|
CreateLink(ctx context.Context, link *entities.UserProviderLink) error
|
||||||
|
|
||||||
|
// GetLink retrieves a user provider link
|
||||||
|
GetLink(ctx context.Context, userID, providerID bson.ObjectID) (*entities.UserProviderLink, error)
|
||||||
|
|
||||||
|
// GetLinkByProviderUserID retrieves a link by provider user ID
|
||||||
|
GetLinkByProviderUserID(ctx context.Context, providerID bson.ObjectID, providerUserID string) (*entities.UserProviderLink, error)
|
||||||
|
|
||||||
|
// GetUserLinks retrieves all provider links for a user
|
||||||
|
GetUserLinks(ctx context.Context, userID bson.ObjectID) ([]*entities.UserProviderLink, error)
|
||||||
|
|
||||||
|
// UpdateLink updates a provider link
|
||||||
|
UpdateLink(ctx context.Context, link *entities.UserProviderLink) error
|
||||||
|
|
||||||
|
// DeleteLink deletes a provider link
|
||||||
|
DeleteLink(ctx context.Context, id bson.ObjectID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteRevisionRepository defines note revision operations
|
||||||
|
type NoteRevisionRepository interface {
|
||||||
|
// CreateRevision creates a new note revision
|
||||||
|
CreateRevision(ctx context.Context, revision *entities.NoteRevision) error
|
||||||
|
|
||||||
|
// GetRevisionsByNoteID retrieves all revisions for a note
|
||||||
|
GetRevisionsByNoteID(ctx context.Context, noteID bson.ObjectID) ([]*entities.NoteRevision, error)
|
||||||
|
|
||||||
|
// GetRevisionByID retrieves a specific revision
|
||||||
|
GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error)
|
||||||
|
}
|
||||||
145
backend/internal/infrastructure/auth/jwt.go
Normal file
145
backend/internal/infrastructure/auth/jwt.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JWTManager handles JWT token creation and verification
|
||||||
|
type JWTManager struct {
|
||||||
|
secretKey string
|
||||||
|
issuer string
|
||||||
|
duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWTClaims represents custom JWT claims
|
||||||
|
type JWTClaims struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokenClaims represents refresh token claims
|
||||||
|
type RefreshTokenClaims struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJWTManager creates a new JWT manager
|
||||||
|
func NewJWTManager(secretKey, issuer string, duration time.Duration) *JWTManager {
|
||||||
|
return &JWTManager{
|
||||||
|
secretKey: secretKey,
|
||||||
|
issuer: issuer,
|
||||||
|
duration: duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAccessToken generates a new access token
|
||||||
|
func (m *JWTManager) GenerateAccessToken(userID, email, username string) (string, error) {
|
||||||
|
claims := JWTClaims{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
Username: username,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.duration)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Issuer: m.issuer,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(m.secretKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRefreshToken generates a new refresh token
|
||||||
|
func (m *JWTManager) GenerateRefreshToken(userID string) (string, error) {
|
||||||
|
claims := RefreshTokenClaims{
|
||||||
|
UserID: userID,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour * 7)), // 7 days
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Issuer: m.issuer,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(m.secretKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAccessToken verifies and parses an access token
|
||||||
|
func (m *JWTManager) VerifyAccessToken(tokenString string) (*JWTClaims, error) {
|
||||||
|
claims := &JWTClaims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return []byte(m.secretKey), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyRefreshToken verifies and parses a refresh token
|
||||||
|
func (m *JWTManager) VerifyRefreshToken(tokenString string) (*RefreshTokenClaims, error) {
|
||||||
|
claims := &RefreshTokenClaims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return []byte(m.secretKey), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRandomToken generates a random token for password reset, etc.
|
||||||
|
func GenerateRandomToken(length int) (string, error) {
|
||||||
|
token := make([]byte, length)
|
||||||
|
if _, err := rand.Read(token); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePKCEChallenge generates a PKCE code challenge
|
||||||
|
func GeneratePKCEChallenge() (codeVerifier, codeChallenge string, err error) {
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
codeVerifier = hex.EncodeToString(bytes)
|
||||||
|
|
||||||
|
// For code_challenge, we'd need base64url encoding of SHA256(verifier)
|
||||||
|
// For simplicity in this example, using hex, but in production use base64url(sha256(verifier))
|
||||||
|
codeChallenge = codeVerifier
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateStateToken generates a state token for OAuth/OIDC flows
|
||||||
|
func GenerateStateToken() (string, error) {
|
||||||
|
return GenerateRandomToken(16)
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccountRecoveryRepository implements account recovery operations
|
||||||
|
type AccountRecoveryRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
type featureFlagSettings struct {
|
||||||
|
ID string `bson:"_id"`
|
||||||
|
Flags entities.FeatureFlags `bson:"flags"`
|
||||||
|
UpdatedAt time.Time `bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeatureFlagRepository implements app-wide feature flag operations.
|
||||||
|
type FeatureFlagRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFeatureFlagRepository creates a new feature flag repository.
|
||||||
|
func NewFeatureFlagRepository(db *mongo.Database) *FeatureFlagRepository {
|
||||||
|
return &FeatureFlagRepository{
|
||||||
|
collection: db.Collection("app_settings"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeatureFlags returns persisted feature flags or defaults when not set.
|
||||||
|
func (r *FeatureFlagRepository) GetFeatureFlags(ctx context.Context) (*entities.FeatureFlags, error) {
|
||||||
|
var settings featureFlagSettings
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"_id": "feature_flags"}).Decode(&settings)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return entities.NewDefaultFeatureFlags(), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := settings.Flags
|
||||||
|
return &flags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFeatureFlags persists feature flags.
|
||||||
|
func (r *FeatureFlagRepository) UpdateFeatureFlags(ctx context.Context, flags *entities.FeatureFlags) error {
|
||||||
|
if flags == nil {
|
||||||
|
flags = entities.NewDefaultFeatureFlags()
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
_, err := r.collection.UpdateOne(
|
||||||
|
ctx,
|
||||||
|
bson.M{"_id": "feature_flags"},
|
||||||
|
bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"flags": flags,
|
||||||
|
"updated_at": now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options.UpdateOne().SetUpsert(true),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAccountRecoveryRepository creates a new recovery repository
|
||||||
|
func NewAccountRecoveryRepository(db *mongo.Database) *AccountRecoveryRepository {
|
||||||
|
return &AccountRecoveryRepository{
|
||||||
|
collection: db.Collection("account_recovery"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRecovery creates a new recovery token
|
||||||
|
func (r *AccountRecoveryRepository) CreateRecovery(ctx context.Context, recovery *entities.AccountRecovery) error {
|
||||||
|
recovery.ID = bson.NewObjectID()
|
||||||
|
recovery.CreatedAt = time.Now()
|
||||||
|
|
||||||
|
_, err := r.collection.InsertOne(ctx, recovery)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecoveryByToken retrieves a recovery record by token
|
||||||
|
func (r *AccountRecoveryRepository) GetRecoveryByToken(ctx context.Context, token string) (*entities.AccountRecovery, error) {
|
||||||
|
var recovery entities.AccountRecovery
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{
|
||||||
|
"token": token,
|
||||||
|
"expires_at": bson.M{"$gt": time.Now()},
|
||||||
|
"used_at": bson.M{"$exists": false},
|
||||||
|
}).Decode(&recovery)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("recovery token not found or expired")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &recovery, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRecoveryUsed marks a recovery token as used
|
||||||
|
func (r *AccountRecoveryRepository) MarkRecoveryUsed(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
now := time.Now()
|
||||||
|
_, err := r.collection.UpdateOne(ctx, bson.M{"_id": id}, bson.M{
|
||||||
|
"$set": bson.M{"used_at": now},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteRevisionRepository implements note revision operations
|
||||||
|
type NoteRevisionRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNoteRevisionRepository creates a new revision repository
|
||||||
|
func NewNoteRevisionRepository(db *mongo.Database) *NoteRevisionRepository {
|
||||||
|
return &NoteRevisionRepository{
|
||||||
|
collection: db.Collection("note_revisions"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRevision creates a new note revision
|
||||||
|
func (r *NoteRevisionRepository) CreateRevision(ctx context.Context, revision *entities.NoteRevision) error {
|
||||||
|
revision.ID = bson.NewObjectID()
|
||||||
|
revision.CreatedAt = time.Now()
|
||||||
|
|
||||||
|
_, err := r.collection.InsertOne(ctx, revision)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRevisionsByNoteID retrieves all revisions for a note
|
||||||
|
func (r *NoteRevisionRepository) GetRevisionsByNoteID(ctx context.Context, noteID bson.ObjectID) ([]*entities.NoteRevision, error) {
|
||||||
|
var revisions []*entities.NoteRevision
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"note_id": noteID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &revisions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return revisions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRevisionByID retrieves a specific revision
|
||||||
|
func (r *NoteRevisionRepository) GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error) {
|
||||||
|
var revision entities.NoteRevision
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&revision)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("revision not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &revision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthProviderRepository implements auth provider operations
|
||||||
|
type AuthProviderRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthProviderRepository creates a new provider repository
|
||||||
|
func NewAuthProviderRepository(db *mongo.Database) *AuthProviderRepository {
|
||||||
|
return &AuthProviderRepository{
|
||||||
|
collection: db.Collection("auth_providers"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProvider creates a new provider
|
||||||
|
func (r *AuthProviderRepository) CreateProvider(ctx context.Context, provider *entities.AuthProvider) error {
|
||||||
|
provider.ID = bson.NewObjectID()
|
||||||
|
provider.CreatedAt = time.Now()
|
||||||
|
provider.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
_, err := r.collection.InsertOne(ctx, provider)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProviderByID retrieves a provider by ID
|
||||||
|
func (r *AuthProviderRepository) GetProviderByID(ctx context.Context, id bson.ObjectID) (*entities.AuthProvider, error) {
|
||||||
|
var provider entities.AuthProvider
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&provider)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("provider not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &provider, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllProviders retrieves all active providers
|
||||||
|
func (r *AuthProviderRepository) GetAllProviders(ctx context.Context) ([]*entities.AuthProvider, error) {
|
||||||
|
var providers []*entities.AuthProvider
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"is_active": true})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &providers); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProvider updates a provider
|
||||||
|
func (r *AuthProviderRepository) UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error {
|
||||||
|
provider.UpdatedAt = time.Now()
|
||||||
|
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": provider.ID}, provider)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProvider deletes a provider
|
||||||
|
func (r *AuthProviderRepository) DeleteProvider(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserProviderLinkRepository implements user provider link operations
|
||||||
|
type UserProviderLinkRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserProviderLinkRepository creates a new link repository
|
||||||
|
func NewUserProviderLinkRepository(db *mongo.Database) *UserProviderLinkRepository {
|
||||||
|
return &UserProviderLinkRepository{
|
||||||
|
collection: db.Collection("user_provider_links"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLink creates a new user provider link
|
||||||
|
func (r *UserProviderLinkRepository) CreateLink(ctx context.Context, link *entities.UserProviderLink) error {
|
||||||
|
link.ID = bson.NewObjectID()
|
||||||
|
link.LinkedAt = time.Now()
|
||||||
|
|
||||||
|
_, err := r.collection.InsertOne(ctx, link)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLink retrieves a user provider link
|
||||||
|
func (r *UserProviderLinkRepository) GetLink(ctx context.Context, userID, providerID bson.ObjectID) (*entities.UserProviderLink, error) {
|
||||||
|
var link entities.UserProviderLink
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{
|
||||||
|
"user_id": userID,
|
||||||
|
"provider_id": providerID,
|
||||||
|
}).Decode(&link)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("link not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLinkByProviderUserID retrieves a link by provider user ID
|
||||||
|
func (r *UserProviderLinkRepository) GetLinkByProviderUserID(ctx context.Context, providerID bson.ObjectID, providerUserID string) (*entities.UserProviderLink, error) {
|
||||||
|
var link entities.UserProviderLink
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{
|
||||||
|
"provider_id": providerID,
|
||||||
|
"provider_user_id": providerUserID,
|
||||||
|
}).Decode(&link)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("link not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserLinks retrieves all provider links for a user
|
||||||
|
func (r *UserProviderLinkRepository) GetUserLinks(ctx context.Context, userID bson.ObjectID) ([]*entities.UserProviderLink, error) {
|
||||||
|
var links []*entities.UserProviderLink
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"user_id": userID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &links); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLink updates a provider link
|
||||||
|
func (r *UserProviderLinkRepository) UpdateLink(ctx context.Context, link *entities.UserProviderLink) error {
|
||||||
|
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": link.ID}, link)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLink deletes a provider link
|
||||||
|
func (r *UserProviderLinkRepository) DeleteLink(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||||
|
return err
|
||||||
|
}
|
||||||
92
backend/internal/infrastructure/database/database.go
Normal file
92
backend/internal/infrastructure/database/database.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Database holds all repository instances
|
||||||
|
type Database struct {
|
||||||
|
Client *mongo.Client
|
||||||
|
DB *mongo.Database
|
||||||
|
UserRepo *UserRepository
|
||||||
|
SpaceRepo *SpaceRepository
|
||||||
|
MembershipRepo *MembershipRepository
|
||||||
|
NoteRepo *NoteRepository
|
||||||
|
CategoryRepo *CategoryRepository
|
||||||
|
RevisionRepo *NoteRevisionRepository
|
||||||
|
GroupRepo *PermissionGroupRepository
|
||||||
|
ProviderRepo *AuthProviderRepository
|
||||||
|
LinkRepo *UserProviderLinkRepository
|
||||||
|
RecoveryRepo *AccountRecoveryRepository
|
||||||
|
FeatureFlagRepo *FeatureFlagRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDatabase initializes a new database connection and repositories
|
||||||
|
func NewDatabase(ctx context.Context, mongoURL string) (*Database, error) {
|
||||||
|
client, err := mongo.Connect(options.Client().ApplyURI(mongoURL))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify connection
|
||||||
|
if err = client.Ping(ctx, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
db := client.Database("noteapp")
|
||||||
|
|
||||||
|
// Create repositories
|
||||||
|
database := &Database{
|
||||||
|
Client: client,
|
||||||
|
DB: db,
|
||||||
|
UserRepo: NewUserRepository(db),
|
||||||
|
SpaceRepo: NewSpaceRepository(db),
|
||||||
|
MembershipRepo: NewMembershipRepository(db),
|
||||||
|
NoteRepo: NewNoteRepository(db),
|
||||||
|
CategoryRepo: NewCategoryRepository(db),
|
||||||
|
RevisionRepo: NewNoteRevisionRepository(db),
|
||||||
|
GroupRepo: NewPermissionGroupRepository(db),
|
||||||
|
ProviderRepo: NewAuthProviderRepository(db),
|
||||||
|
LinkRepo: NewUserProviderLinkRepository(db),
|
||||||
|
RecoveryRepo: NewAccountRecoveryRepository(db),
|
||||||
|
FeatureFlagRepo: NewFeatureFlagRepository(db),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all indexes are created
|
||||||
|
if err := database.EnsureIndexes(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return database, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureIndexes ensures all necessary indexes are created
|
||||||
|
func (d *Database) EnsureIndexes(ctx context.Context) error {
|
||||||
|
if err := d.UserRepo.EnsureIndexes(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := d.SpaceRepo.EnsureIndexes(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := d.MembershipRepo.EnsureIndexes(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := d.NoteRepo.EnsureIndexes(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := d.CategoryRepo.EnsureIndexes(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := d.GroupRepo.EnsureIndexes(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection
|
||||||
|
func (d *Database) Close(ctx context.Context) error {
|
||||||
|
return d.Client.Disconnect(ctx)
|
||||||
|
}
|
||||||
129
backend/internal/infrastructure/database/group_repository.go
Normal file
129
backend/internal/infrastructure/database/group_repository.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PermissionGroupRepository implements permission group data access.
|
||||||
|
type PermissionGroupRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPermissionGroupRepository creates a new group repository.
|
||||||
|
func NewPermissionGroupRepository(db *mongo.Database) *PermissionGroupRepository {
|
||||||
|
return &PermissionGroupRepository{collection: db.Collection("permission_groups")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGroup creates a new permission group.
|
||||||
|
func (r *PermissionGroupRepository) CreateGroup(ctx context.Context, group *entities.PermissionGroup) error {
|
||||||
|
group.ID = bson.NewObjectID()
|
||||||
|
group.Name = strings.TrimSpace(group.Name)
|
||||||
|
group.NameKey = strings.ToLower(group.Name)
|
||||||
|
group.CreatedAt = time.Now()
|
||||||
|
group.UpdatedAt = time.Now()
|
||||||
|
_, err := r.collection.InsertOne(ctx, group)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupByID retrieves a group by ID.
|
||||||
|
func (r *PermissionGroupRepository) GetGroupByID(ctx context.Context, id bson.ObjectID) (*entities.PermissionGroup, error) {
|
||||||
|
var group entities.PermissionGroup
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&group)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("group not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupByName retrieves a group by case-insensitive name.
|
||||||
|
func (r *PermissionGroupRepository) GetGroupByName(ctx context.Context, name string) (*entities.PermissionGroup, error) {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
var group entities.PermissionGroup
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"name_key": normalized}).Decode(&group)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("group not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupsByIDs retrieves groups by IDs.
|
||||||
|
func (r *PermissionGroupRepository) GetGroupsByIDs(ctx context.Context, ids []bson.ObjectID) ([]*entities.PermissionGroup, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []*entities.PermissionGroup{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"_id": bson.M{"$in": ids}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var groups []*entities.PermissionGroup
|
||||||
|
if err := cursor.All(ctx, &groups); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGroups retrieves all groups sorted by name.
|
||||||
|
func (r *PermissionGroupRepository) ListGroups(ctx context.Context) ([]*entities.PermissionGroup, error) {
|
||||||
|
opts := options.Find().SetSort(bson.D{{Key: "name", Value: 1}})
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var groups []*entities.PermissionGroup
|
||||||
|
if err := cursor.All(ctx, &groups); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGroup updates an existing group.
|
||||||
|
func (r *PermissionGroupRepository) UpdateGroup(ctx context.Context, group *entities.PermissionGroup) error {
|
||||||
|
group.UpdatedAt = time.Now()
|
||||||
|
_, err := r.collection.UpdateOne(ctx, bson.M{"_id": group.ID}, bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"name": strings.TrimSpace(group.Name),
|
||||||
|
"name_key": strings.ToLower(strings.TrimSpace(group.Name)),
|
||||||
|
"description": group.Description,
|
||||||
|
"permissions": group.Permissions,
|
||||||
|
"is_system": group.IsSystem,
|
||||||
|
"updated_at": group.UpdatedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGroup deletes a group.
|
||||||
|
func (r *PermissionGroupRepository) DeleteGroup(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureIndexes creates indexes for the permission groups collection.
|
||||||
|
func (r *PermissionGroupRepository) EnsureIndexes(ctx context.Context) error {
|
||||||
|
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||||
|
{
|
||||||
|
Keys: bson.D{{Key: "name_key", Value: 1}},
|
||||||
|
Options: options.Index().SetUnique(true),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
338
backend/internal/infrastructure/database/note_repository.go
Normal file
338
backend/internal/infrastructure/database/note_repository.go
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NoteRepository implements the note repository interface
|
||||||
|
type NoteRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
func notePrioritySortOptions(skip, limit int) *options.FindOptionsBuilder {
|
||||||
|
return options.Find().
|
||||||
|
SetSkip(int64(skip)).
|
||||||
|
SetLimit(int64(limit)).
|
||||||
|
SetSort(bson.D{
|
||||||
|
{Key: "is_pinned", Value: -1},
|
||||||
|
{Key: "is_favorite", Value: -1},
|
||||||
|
{Key: "title", Value: 1},
|
||||||
|
}).
|
||||||
|
SetCollation(&options.Collation{Locale: "en", Strength: 2})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNoteRepository creates a new note repository
|
||||||
|
func NewNoteRepository(db *mongo.Database) *NoteRepository {
|
||||||
|
return &NoteRepository{
|
||||||
|
collection: db.Collection("notes"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNote creates a new note
|
||||||
|
func (r *NoteRepository) CreateNote(ctx context.Context, note *entities.Note) error {
|
||||||
|
note.ID = bson.NewObjectID()
|
||||||
|
note.CreatedAt = time.Now()
|
||||||
|
note.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
_, err := r.collection.InsertOne(ctx, note)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNoteByID retrieves a note by ID
|
||||||
|
func (r *NoteRepository) GetNoteByID(ctx context.Context, id bson.ObjectID) (*entities.Note, error) {
|
||||||
|
var note entities.Note
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(¬e)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("note not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ¬e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotesBySpaceID retrieves all notes in a space with pagination
|
||||||
|
func (r *NoteRepository) GetNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error) {
|
||||||
|
var notes []*entities.Note
|
||||||
|
|
||||||
|
opts := notePrioritySortOptions(skip, limit)
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, ¬es); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return notes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicNotesBySpaceID retrieves public notes in a space with pagination.
|
||||||
|
func (r *NoteRepository) GetPublicNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error) {
|
||||||
|
var notes []*entities.Note
|
||||||
|
|
||||||
|
opts := notePrioritySortOptions(skip, limit)
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID, "is_public": true}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, ¬es); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return notes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotesByCategory retrieves notes in a category
|
||||||
|
func (r *NoteRepository) GetNotesByCategory(ctx context.Context, spaceID, categoryID bson.ObjectID) ([]*entities.Note, error) {
|
||||||
|
var notes []*entities.Note
|
||||||
|
|
||||||
|
opts := options.Find().
|
||||||
|
SetSort(bson.D{
|
||||||
|
{Key: "is_pinned", Value: -1},
|
||||||
|
{Key: "is_favorite", Value: -1},
|
||||||
|
{Key: "title", Value: 1},
|
||||||
|
}).
|
||||||
|
SetCollation(&options.Collation{Locale: "en", Strength: 2})
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{
|
||||||
|
"space_id": spaceID,
|
||||||
|
"category_id": categoryID,
|
||||||
|
}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, ¬es); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return notes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchNotes performs full-text search on notes
|
||||||
|
func (r *NoteRepository) SearchNotes(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Note, error) {
|
||||||
|
var notes []*entities.Note
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{
|
||||||
|
"space_id": spaceID,
|
||||||
|
"$text": bson.M{
|
||||||
|
"$search": query,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, ¬es); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return notes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNote updates a note
|
||||||
|
func (r *NoteRepository) UpdateNote(ctx context.Context, note *entities.Note) error {
|
||||||
|
note.UpdatedAt = time.Now()
|
||||||
|
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": note.ID}, note)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNote deletes a note
|
||||||
|
func (r *NoteRepository) DeleteNote(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNotesBySpaceID deletes all notes in a space
|
||||||
|
func (r *NoteRepository) DeleteNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureIndexes creates necessary indexes
|
||||||
|
func (r *NoteRepository) EnsureIndexes(ctx context.Context) error {
|
||||||
|
indexModel := []mongo.IndexModel{
|
||||||
|
{
|
||||||
|
Keys: bson.D{bson.E{Key: "space_id", Value: 1}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{bson.E{Key: "category_id", Value: 1}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{
|
||||||
|
bson.E{Key: "title", Value: "text"},
|
||||||
|
bson.E{Key: "content", Value: "text"},
|
||||||
|
bson.E{Key: "tags", Value: "text"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{bson.E{Key: "updated_at", Value: -1}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{
|
||||||
|
{Key: "space_id", Value: 1},
|
||||||
|
{Key: "is_pinned", Value: -1},
|
||||||
|
{Key: "is_favorite", Value: -1},
|
||||||
|
{Key: "title", Value: 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{
|
||||||
|
{Key: "space_id", Value: 1},
|
||||||
|
{Key: "is_public", Value: 1},
|
||||||
|
{Key: "is_pinned", Value: -1},
|
||||||
|
{Key: "is_favorite", Value: -1},
|
||||||
|
{Key: "title", Value: 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== CATEGORY REPOSITORY ==========
|
||||||
|
|
||||||
|
// CategoryRepository implements the category repository interface
|
||||||
|
type CategoryRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCategoryRepository creates a new category repository
|
||||||
|
func NewCategoryRepository(db *mongo.Database) *CategoryRepository {
|
||||||
|
return &CategoryRepository{
|
||||||
|
collection: db.Collection("categories"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCategory creates a new category
|
||||||
|
func (r *CategoryRepository) CreateCategory(ctx context.Context, category *entities.Category) error {
|
||||||
|
category.ID = bson.NewObjectID()
|
||||||
|
category.CreatedAt = time.Now()
|
||||||
|
category.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
_, err := r.collection.InsertOne(ctx, category)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCategoryByID retrieves a category by ID
|
||||||
|
func (r *CategoryRepository) GetCategoryByID(ctx context.Context, id bson.ObjectID) (*entities.Category, error) {
|
||||||
|
var category entities.Category
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&category)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("category not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &category, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCategoriesBySpaceID retrieves all categories in a space
|
||||||
|
func (r *CategoryRepository) GetCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error) {
|
||||||
|
var categories []*entities.Category
|
||||||
|
|
||||||
|
opts := options.Find().SetSort(bson.M{"order": 1})
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &categories); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRootCategories retrieves root level categories in a space
|
||||||
|
func (r *CategoryRepository) GetRootCategories(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error) {
|
||||||
|
var categories []*entities.Category
|
||||||
|
|
||||||
|
opts := options.Find().SetSort(bson.M{"order": 1})
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{
|
||||||
|
"space_id": spaceID,
|
||||||
|
"parent_id": nil,
|
||||||
|
}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &categories); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubcategories retrieves subcategories of a category
|
||||||
|
func (r *CategoryRepository) GetSubcategories(ctx context.Context, parentID bson.ObjectID) ([]*entities.Category, error) {
|
||||||
|
var categories []*entities.Category
|
||||||
|
|
||||||
|
opts := options.Find().SetSort(bson.M{"order": 1})
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"parent_id": parentID}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &categories); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCategory updates a category
|
||||||
|
func (r *CategoryRepository) UpdateCategory(ctx context.Context, category *entities.Category) error {
|
||||||
|
category.UpdatedAt = time.Now()
|
||||||
|
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": category.ID}, category)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCategory deletes a category
|
||||||
|
func (r *CategoryRepository) DeleteCategory(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCategoriesBySpaceID deletes all categories in a space
|
||||||
|
func (r *CategoryRepository) DeleteCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureIndexes creates necessary indexes
|
||||||
|
func (r *CategoryRepository) EnsureIndexes(ctx context.Context) error {
|
||||||
|
indexModel := []mongo.IndexModel{
|
||||||
|
{
|
||||||
|
Keys: bson.D{bson.E{Key: "space_id", Value: 1}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{bson.E{Key: "parent_id", Value: 1}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{bson.E{Key: "order", Value: 1}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
|
||||||
|
return err
|
||||||
|
}
|
||||||
249
backend/internal/infrastructure/database/space_repository.go
Normal file
249
backend/internal/infrastructure/database/space_repository.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SpaceRepository implements the space repository interface
|
||||||
|
type SpaceRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSpaceRepository creates a new space repository
|
||||||
|
func NewSpaceRepository(db *mongo.Database) *SpaceRepository {
|
||||||
|
return &SpaceRepository{
|
||||||
|
collection: db.Collection("spaces"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSpace creates a new space
|
||||||
|
func (r *SpaceRepository) CreateSpace(ctx context.Context, space *entities.Space) error {
|
||||||
|
space.ID = bson.NewObjectID()
|
||||||
|
space.CreatedAt = time.Now()
|
||||||
|
space.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
_, err := r.collection.InsertOne(ctx, space)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpaceByID retrieves a space by ID
|
||||||
|
func (r *SpaceRepository) GetSpaceByID(ctx context.Context, id bson.ObjectID) (*entities.Space, error) {
|
||||||
|
var space entities.Space
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&space)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("space not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &space, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpacesByUserID retrieves all spaces for a user (via memberships)
|
||||||
|
func (r *SpaceRepository) GetSpacesByUserID(ctx context.Context, userID bson.ObjectID) ([]*entities.Space, error) {
|
||||||
|
var spaces []*entities.Space
|
||||||
|
|
||||||
|
// Query spaces where user is a member
|
||||||
|
opts := options.Find().SetSort(bson.M{"created_at": -1})
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
// This would typically be joined with membership collection
|
||||||
|
// For now, returning all spaces - in production, filter by membership
|
||||||
|
if err = cursor.All(ctx, &spaces); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return spaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSpace updates a space
|
||||||
|
func (r *SpaceRepository) UpdateSpace(ctx context.Context, space *entities.Space) error {
|
||||||
|
space.UpdatedAt = time.Now()
|
||||||
|
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": space.ID}, space)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSpace deletes a space
|
||||||
|
func (r *SpaceRepository) DeleteSpace(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllSpaces retrieves all spaces sorted by creation date descending
|
||||||
|
func (r *SpaceRepository) GetAllSpaces(ctx context.Context) ([]*entities.Space, error) {
|
||||||
|
opts := options.Find().SetSort(bson.D{bson.E{Key: "created_at", Value: -1}})
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var spaces []*entities.Space
|
||||||
|
if err := cursor.All(ctx, &spaces); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return spaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicSpaces retrieves all spaces marked as public
|
||||||
|
func (r *SpaceRepository) GetPublicSpaces(ctx context.Context) ([]*entities.Space, error) {
|
||||||
|
opts := options.Find().SetSort(bson.D{bson.E{Key: "created_at", Value: -1}})
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"is_public": true}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var spaces []*entities.Space
|
||||||
|
if err := cursor.All(ctx, &spaces); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return spaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureIndexes creates necessary indexes
|
||||||
|
func (r *SpaceRepository) EnsureIndexes(ctx context.Context) error {
|
||||||
|
indexModel := []mongo.IndexModel{
|
||||||
|
{
|
||||||
|
Keys: bson.D{bson.E{Key: "owner_id", Value: 1}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{bson.E{Key: "created_at", Value: -1}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== MEMBERSHIP REPOSITORY ==========
|
||||||
|
|
||||||
|
// MembershipRepository implements the membership repository interface
|
||||||
|
type MembershipRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMembershipRepository creates a new membership repository
|
||||||
|
func NewMembershipRepository(db *mongo.Database) *MembershipRepository {
|
||||||
|
return &MembershipRepository{
|
||||||
|
collection: db.Collection("memberships"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMembership creates a new membership
|
||||||
|
func (r *MembershipRepository) CreateMembership(ctx context.Context, membership *entities.Membership) error {
|
||||||
|
membership.ID = bson.NewObjectID()
|
||||||
|
membership.JoinedAt = time.Now()
|
||||||
|
|
||||||
|
_, err := r.collection.InsertOne(ctx, membership)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMembershipByID retrieves a membership by ID
|
||||||
|
func (r *MembershipRepository) GetMembershipByID(ctx context.Context, id bson.ObjectID) (*entities.Membership, error) {
|
||||||
|
var membership entities.Membership
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&membership)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("membership not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &membership, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserMembership retrieves a membership for a user in a space
|
||||||
|
func (r *MembershipRepository) GetUserMembership(ctx context.Context, userID, spaceID bson.ObjectID) (*entities.Membership, error) {
|
||||||
|
var membership entities.Membership
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{
|
||||||
|
"user_id": userID,
|
||||||
|
"space_id": spaceID,
|
||||||
|
}).Decode(&membership)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("membership not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &membership, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpaceMembers retrieves all members in a space
|
||||||
|
func (r *MembershipRepository) GetSpaceMembers(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Membership, error) {
|
||||||
|
var memberships []*entities.Membership
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &memberships); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberships, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserMemberships retrieves all memberships for a user
|
||||||
|
func (r *MembershipRepository) GetUserMemberships(ctx context.Context, userID bson.ObjectID) ([]*entities.Membership, error) {
|
||||||
|
var memberships []*entities.Membership
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"user_id": userID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
if err = cursor.All(ctx, &memberships); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberships, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMembership updates a membership
|
||||||
|
func (r *MembershipRepository) UpdateMembership(ctx context.Context, membership *entities.Membership) error {
|
||||||
|
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": membership.ID}, membership)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMembership deletes a membership
|
||||||
|
func (r *MembershipRepository) DeleteMembership(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMembershipsBySpaceID deletes all memberships for a space
|
||||||
|
func (r *MembershipRepository) DeleteMembershipsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureIndexes creates necessary indexes
|
||||||
|
func (r *MembershipRepository) EnsureIndexes(ctx context.Context) error {
|
||||||
|
indexModel := []mongo.IndexModel{
|
||||||
|
{
|
||||||
|
Keys: bson.D{bson.E{Key: "user_id", Value: 1}, bson.E{Key: "space_id", Value: 1}},
|
||||||
|
Options: options.Index().SetUnique(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{bson.E{Key: "space_id", Value: 1}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
|
||||||
|
return err
|
||||||
|
}
|
||||||
120
backend/internal/infrastructure/database/user_repository.go
Normal file
120
backend/internal/infrastructure/database/user_repository.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserRepository implements the user repository interface
|
||||||
|
type UserRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserRepository creates a new user repository
|
||||||
|
func NewUserRepository(db *mongo.Database) *UserRepository {
|
||||||
|
return &UserRepository{
|
||||||
|
collection: db.Collection("users"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser creates a new user
|
||||||
|
func (r *UserRepository) CreateUser(ctx context.Context, user *entities.User) error {
|
||||||
|
user.ID = bson.NewObjectID()
|
||||||
|
user.CreatedAt = time.Now()
|
||||||
|
user.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
_, err := r.collection.InsertOne(ctx, user)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID retrieves a user by ID
|
||||||
|
func (r *UserRepository) GetUserByID(ctx context.Context, id bson.ObjectID) (*entities.User, error) {
|
||||||
|
var user entities.User
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&user)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByEmail retrieves a user by email
|
||||||
|
func (r *UserRepository) GetUserByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||||
|
var user entities.User
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByUsername retrieves a user by username
|
||||||
|
func (r *UserRepository) GetUserByUsername(ctx context.Context, username string) (*entities.User, error) {
|
||||||
|
var user entities.User
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"username": username}).Decode(&user)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser updates a user
|
||||||
|
func (r *UserRepository) UpdateUser(ctx context.Context, user *entities.User) error {
|
||||||
|
user.UpdatedAt = time.Now()
|
||||||
|
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": user.ID}, user)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser deletes a user
|
||||||
|
func (r *UserRepository) DeleteUser(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllUsers retrieves all users sorted by creation date descending
|
||||||
|
func (r *UserRepository) ListAllUsers(ctx context.Context) ([]*entities.User, error) {
|
||||||
|
opts := options.Find().SetSort(bson.D{bson.E{Key: "created_at", Value: -1}})
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{}, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var users []*entities.User
|
||||||
|
if err := cursor.All(ctx, &users); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureIndexes creates necessary indexes for users collection
|
||||||
|
func (r *UserRepository) EnsureIndexes(ctx context.Context) error {
|
||||||
|
indexModel := []mongo.IndexModel{
|
||||||
|
{
|
||||||
|
Keys: bson.D{bson.E{Key: "email", Value: 1}},
|
||||||
|
Options: options.Index().SetUnique(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{bson.E{Key: "username", Value: 1}},
|
||||||
|
Options: options.Index().SetUnique(true),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := r.collection.Indexes().CreateMany(ctx, indexModel)
|
||||||
|
return err
|
||||||
|
}
|
||||||
79
backend/internal/infrastructure/security/encryption.go
Normal file
79
backend/internal/infrastructure/security/encryption.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encryptor provides encryption/decryption for sensitive data
|
||||||
|
type Encryptor struct {
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEncryptor creates a new encryptor with the given key
|
||||||
|
// The key must be 32 bytes for AES-256
|
||||||
|
func NewEncryptor(key string) (*Encryptor, error) {
|
||||||
|
if len(key) != 32 {
|
||||||
|
return nil, errors.New("encryption key must be 32 bytes (256 bits)")
|
||||||
|
}
|
||||||
|
return &Encryptor{
|
||||||
|
key: []byte(key),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt encrypts data using AES-256-GCM
|
||||||
|
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
|
||||||
|
block, err := aes.NewCipher(e.key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts data encrypted with Encrypt
|
||||||
|
func (e *Encryptor) Decrypt(ciphertext string) (string, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(e.key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(data) < nonceSize {
|
||||||
|
return "", errors.New("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := data[:nonceSize]
|
||||||
|
ciphertextBytes := data[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
121
backend/internal/infrastructure/security/password.go
Normal file
121
backend/internal/infrastructure/security/password.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordHasher provides password hashing and verification
|
||||||
|
type PasswordHasher struct {
|
||||||
|
time uint32
|
||||||
|
memory uint32
|
||||||
|
threads uint8
|
||||||
|
keyLen uint32
|
||||||
|
saltLen int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPasswordHasher creates a new password hasher with sensible defaults
|
||||||
|
func NewPasswordHasher() *PasswordHasher {
|
||||||
|
return &PasswordHasher{
|
||||||
|
time: 1,
|
||||||
|
memory: 64 * 1024, // 64 MB
|
||||||
|
threads: 4,
|
||||||
|
keyLen: 32,
|
||||||
|
saltLen: 16,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashPassword hashes a password using Argon2id
|
||||||
|
// Returns hash in format "$argon2id$v=19$m=65536,t=1,p=4$salt$hash"
|
||||||
|
func (ph *PasswordHasher) HashPassword(password string) (string, error) {
|
||||||
|
salt := make([]byte, ph.saltLen)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := argon2.IDKey(
|
||||||
|
[]byte(password),
|
||||||
|
salt,
|
||||||
|
ph.time,
|
||||||
|
ph.memory,
|
||||||
|
ph.threads,
|
||||||
|
ph.keyLen,
|
||||||
|
)
|
||||||
|
|
||||||
|
hashStr := fmt.Sprintf(
|
||||||
|
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||||
|
19,
|
||||||
|
ph.memory,
|
||||||
|
ph.time,
|
||||||
|
ph.threads,
|
||||||
|
hex.EncodeToString(salt),
|
||||||
|
hex.EncodeToString(hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
return hashStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword verifies a password against a hash
|
||||||
|
func (ph *PasswordHasher) VerifyPassword(password, hash string) (bool, error) {
|
||||||
|
// Backward compatibility: accept legacy bcrypt hashes.
|
||||||
|
if strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$") {
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(hash, "$")
|
||||||
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||||
|
return false, errors.New("invalid password hash format")
|
||||||
|
}
|
||||||
|
|
||||||
|
versionPart := strings.TrimPrefix(parts[2], "v=")
|
||||||
|
version, err := strconv.Atoi(versionPart)
|
||||||
|
if err != nil || version != 19 {
|
||||||
|
return false, errors.New("invalid password hash version")
|
||||||
|
}
|
||||||
|
|
||||||
|
var memory, timeCost uint32
|
||||||
|
var threads uint8
|
||||||
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
|
||||||
|
return false, errors.New("invalid password hash parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
saltStr := parts[4]
|
||||||
|
hashStr := parts[5]
|
||||||
|
|
||||||
|
salt, err := hex.DecodeString(saltStr)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.New("invalid salt in password hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedHashBytes, err := hex.DecodeString(hashStr)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.New("invalid hash in password hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the input password with the extracted parameters
|
||||||
|
computedHash := argon2.IDKey(
|
||||||
|
[]byte(password),
|
||||||
|
salt,
|
||||||
|
timeCost,
|
||||||
|
memory,
|
||||||
|
threads,
|
||||||
|
uint32(len(expectedHashBytes)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if subtle.ConstantTimeCompare(computedHash, expectedHashBytes) == 1 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
294
backend/internal/interfaces/handlers/admin_handler.go
Normal file
294
backend/internal/interfaces/handlers/admin_handler.go
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/application/dto"
|
||||||
|
"github.com/noteapp/backend/internal/application/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminHandler handles admin-level HTTP requests
|
||||||
|
type AdminHandler struct {
|
||||||
|
adminService *services.AdminService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminHandler creates a new AdminHandler
|
||||||
|
func NewAdminHandler(adminService *services.AdminService) *AdminHandler {
|
||||||
|
return &AdminHandler{adminService: adminService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers handles GET /admin/users
|
||||||
|
func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := h.adminService.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserGroups handles PUT /admin/users/{userId}/groups
|
||||||
|
func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid user id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdateUserGroupsRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupIDs := make([]bson.ObjectID, 0, len(req.GroupIDs))
|
||||||
|
for _, groupID := range req.GroupIDs {
|
||||||
|
parsed, err := bson.ObjectIDFromHex(groupID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid group id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupIDs = append(groupIDs, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.adminService.UpdateUserGroups(r.Context(), userID, groupIDs)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGroups handles GET /admin/groups
|
||||||
|
func (h *AdminHandler) ListGroups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
groups, err := h.adminService.ListGroups(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"groups": groups})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGroup handles POST /admin/groups
|
||||||
|
func (h *AdminHandler) CreateGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req dto.CreatePermissionGroupRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := h.adminService.CreateGroup(r.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGroup handles PUT /admin/groups/{groupId}
|
||||||
|
func (h *AdminHandler) UpdateGroup(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
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdatePermissionGroupRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := h.adminService.UpdateGroup(r.Context(), groupID, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllSpaces handles GET /admin/spaces
|
||||||
|
func (h *AdminHandler) ListAllSpaces(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaces, err := h.adminService.ListAllSpaces(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"spaces": spaces})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSpace handles PUT /admin/spaces/{spaceId}
|
||||||
|
func (h *AdminHandler) UpdateSpace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.CreateSpaceRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
space, err := h.adminService.UpdateSpace(r.Context(), spaceID, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(space)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSpaceVisibility handles PUT /admin/spaces/{spaceId}/visibility
|
||||||
|
func (h *AdminHandler) SetSpaceVisibility(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.adminService.SetSpaceVisibility(r.Context(), spaceID, req.IsPublic); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"message": "visibility updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSpaceMember handles POST /admin/spaces/{spaceId}/members
|
||||||
|
func (h *AdminHandler) AddSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.AddSpaceMemberRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := bson.ObjectIDFromHex(req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid user id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.adminService.AddSpaceMember(r.Context(), spaceID, userID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"message": "member added"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSpaceMembers handles GET /admin/spaces/{spaceId}/members
|
||||||
|
func (h *AdminHandler) ListSpaceMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := h.adminService.ListSpaceMembers(r.Context(), spaceID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"members": members})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSpaceMember handles DELETE /admin/spaces/{spaceId}/members/{userId}
|
||||||
|
func (h *AdminHandler) RemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid user id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.adminService.RemoveSpaceMember(r.Context(), spaceID, userID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSpace handles DELETE /admin/spaces/{spaceId}
|
||||||
|
func (h *AdminHandler) DeleteSpace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.adminService.DeleteSpace(r.Context(), spaceID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeatureFlags handles GET /admin/feature-flags
|
||||||
|
func (h *AdminHandler) GetFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flags, err := h.adminService.GetFeatureFlags(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFeatureFlags handles PUT /admin/feature-flags
|
||||||
|
func (h *AdminHandler) UpdateFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req dto.UpdateFeatureFlagsRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := h.adminService.UpdateFeatureFlags(r.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(flags)
|
||||||
|
}
|
||||||
299
backend/internal/interfaces/handlers/auth_handler.go
Normal file
299
backend/internal/interfaces/handlers/auth_handler.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/noteapp/backend/internal/application/dto"
|
||||||
|
"github.com/noteapp/backend/internal/application/services"
|
||||||
|
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthHandler handles authentication endpoints
|
||||||
|
type AuthHandler struct {
|
||||||
|
authService *services.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthHandler creates a new auth handler
|
||||||
|
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
|
||||||
|
return &AuthHandler{
|
||||||
|
authService: authService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register handles user registration
|
||||||
|
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.RegisterRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if req.Email == "" || req.Password == "" || req.Username == "" {
|
||||||
|
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.authService.Register(r.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "registration is currently disabled") {
|
||||||
|
http.Error(w, err.Error(), http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login handles user login
|
||||||
|
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.LoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.authService.Login(r.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set secure HTTP-only cookie for refresh token
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "refresh_token",
|
||||||
|
Value: response.RefreshToken,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 7 * 24 * 60 * 60, // 7 days
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecureRequest(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout handles user logout
|
||||||
|
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Clear refresh token cookie
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "refresh_token",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecureRequest(r),
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProviders returns all active OAuth/OIDC providers.
|
||||||
|
func (h *AuthHandler) ListProviders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
providers, err := h.authService.ListProviders(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"providers": providers})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProvider stores a new OAuth/OIDC provider configuration.
|
||||||
|
func (h *AuthHandler) CreateProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req dto.CreateAuthProviderRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := h.authService.CreateProvider(r.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartProviderLogin redirects the browser to the selected provider.
|
||||||
|
func (h *AuthHandler) StartProviderLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid provider ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := auth.GenerateStateToken()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to create OAuth state", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "oauth_state",
|
||||||
|
Value: state,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 10 * 60,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecureRequest(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
redirectURI := buildBackendURL(r, "/api/v1/auth/providers/"+providerID.Hex()+"/callback")
|
||||||
|
authorizationURL, err := h.authService.BuildProviderAuthorizationURL(r.Context(), providerID, redirectURI, state)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, authorizationURL, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteProviderLogin exchanges the authorization code and redirects back to the frontend.
|
||||||
|
func (h *AuthHandler) CompleteProviderLogin(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
|
||||||
|
}
|
||||||
|
|
||||||
|
stateCookie, err := r.Cookie("oauth_state")
|
||||||
|
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
|
||||||
|
http.Error(w, "Invalid OAuth state", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.authService.CompleteProviderLogin(r.Context(), providerID, r.URL.Query().Get("code"), buildBackendURL(r, "/api/v1/auth/providers/"+providerID.Hex()+"/callback"))
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, buildFrontendLoginURL("oauth_error", err.Error(), "", nil), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "oauth_state",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecureRequest(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "refresh_token",
|
||||||
|
Value: response.RefreshToken,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 7 * 24 * 60 * 60,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecureRequest(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
http.Redirect(w, r, buildFrontendLoginURL("oauth_success", "", response.AccessToken, response.User), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken handles token refresh
|
||||||
|
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get refresh token from cookie
|
||||||
|
cookie, err := r.Cookie("refresh_token")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Refresh token not found", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := h.authService.RefreshAccessToken(r.Context(), cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"access_token": accessToken,
|
||||||
|
"expires_in": 3600,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
func (h *AuthHandler) Health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "healthy",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSecureRequest(r *http.Request) bool {
|
||||||
|
if r.TLS != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildBackendURL(r *http.Request, path string) string {
|
||||||
|
scheme := "http"
|
||||||
|
if isSecureRequest(r) {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
return scheme + "://" + r.Host + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFrontendLoginURL(status, message, accessToken string, user *dto.UserDTO) string {
|
||||||
|
frontendURL := os.Getenv("FRONTEND_URL")
|
||||||
|
if frontendURL == "" {
|
||||||
|
frontendURL = "http://localhost:5173"
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(strings.TrimRight(frontendURL, "/") + "/login")
|
||||||
|
if err != nil {
|
||||||
|
return frontendURL + "/login"
|
||||||
|
}
|
||||||
|
|
||||||
|
query := parsed.Query()
|
||||||
|
if status != "" {
|
||||||
|
query.Set("status", status)
|
||||||
|
}
|
||||||
|
if message != "" {
|
||||||
|
query.Set("message", message)
|
||||||
|
}
|
||||||
|
if accessToken != "" {
|
||||||
|
query.Set("access_token", accessToken)
|
||||||
|
}
|
||||||
|
if user != nil {
|
||||||
|
payload, _ := json.Marshal(user)
|
||||||
|
query.Set("user_json", string(payload))
|
||||||
|
query.Set("user", base64.RawURLEncoding.EncodeToString(payload))
|
||||||
|
}
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
212
backend/internal/interfaces/handlers/category_handler.go
Normal file
212
backend/internal/interfaces/handlers/category_handler.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/application/dto"
|
||||||
|
"github.com/noteapp/backend/internal/application/services"
|
||||||
|
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CategoryHandler handles category endpoints
|
||||||
|
type CategoryHandler struct {
|
||||||
|
categoryService *services.CategoryService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCategoryHandler creates a new category handler
|
||||||
|
func NewCategoryHandler(categoryService *services.CategoryService) *CategoryHandler {
|
||||||
|
return &CategoryHandler{categoryService: categoryService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCategoryTree returns the full category tree for a space
|
||||||
|
func (h *CategoryHandler) GetCategoryTree(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := getUserObjectID(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tree, err := h.categoryService.GetCategoryTree(r.Context(), spaceID, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCategory creates a new category in a space
|
||||||
|
func (h *CategoryHandler) CreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := getUserObjectID(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.CreateCategoryRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
category, err := h.categoryService.CreateCategory(r.Context(), spaceID, userID, &req)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "unauthorized" {
|
||||||
|
http.Error(w, err.Error(), http.StatusForbidden)
|
||||||
|
} else {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCategory updates a category
|
||||||
|
func (h *CategoryHandler) UpdateCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := getUserObjectID(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
categoryID, err := bson.ObjectIDFromHex(vars["categoryId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid category id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdateCategoryRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
category, err := h.categoryService.UpdateCategory(r.Context(), categoryID, spaceID, userID, &req)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "unauthorized" {
|
||||||
|
http.Error(w, err.Error(), http.StatusForbidden)
|
||||||
|
} else {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCategory deletes a category
|
||||||
|
func (h *CategoryHandler) DeleteCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := getUserObjectID(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
categoryID, err := bson.ObjectIDFromHex(vars["categoryId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid category id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var moveNotesTo *string
|
||||||
|
if v := r.URL.Query().Get("moveNotesTo"); v != "" {
|
||||||
|
moveNotesTo = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.categoryService.DeleteCategory(r.Context(), categoryID, spaceID, userID, moveNotesTo); err != nil {
|
||||||
|
if err.Error() == "unauthorized" {
|
||||||
|
http.Error(w, err.Error(), http.StatusForbidden)
|
||||||
|
} else {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveCategory moves a category to a new parent
|
||||||
|
func (h *CategoryHandler) MoveCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := getUserObjectID(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
categoryID, err := bson.ObjectIDFromHex(vars["categoryId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid category id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
ParentID *string `json:"parent_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
category, err := h.categoryService.MoveCategory(r.Context(), categoryID, spaceID, userID, body.ParentID)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "unauthorized" {
|
||||||
|
http.Error(w, err.Error(), http.StatusForbidden)
|
||||||
|
} else {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserObjectID extracts the user ObjectID from the request context
|
||||||
|
func getUserObjectID(r *http.Request) (bson.ObjectID, error) {
|
||||||
|
userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string)
|
||||||
|
if !ok || userIDStr == "" {
|
||||||
|
return bson.NilObjectID, http.ErrNoCookie
|
||||||
|
}
|
||||||
|
return bson.ObjectIDFromHex(userIDStr)
|
||||||
|
}
|
||||||
266
backend/internal/interfaces/handlers/note_handler.go
Normal file
266
backend/internal/interfaces/handlers/note_handler.go
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/application/dto"
|
||||||
|
"github.com/noteapp/backend/internal/application/services"
|
||||||
|
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NoteHandler handles note endpoints
|
||||||
|
type NoteHandler struct {
|
||||||
|
noteService *services.NoteService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNoteHandler creates a new note handler
|
||||||
|
func NewNoteHandler(noteService *services.NoteService) *NoteHandler {
|
||||||
|
return &NoteHandler{
|
||||||
|
noteService: noteService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNote creates a new note
|
||||||
|
func (h *NoteHandler) CreateNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceID := mux.Vars(r)["spaceId"]
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
|
||||||
|
var req dto.CreateNoteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
note, err := h.noteService.CreateNote(r.Context(), spaceObjID, userObjID, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNote retrieves a note
|
||||||
|
func (h *NoteHandler) GetNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
spaceID := vars["spaceId"]
|
||||||
|
noteID := vars["noteId"]
|
||||||
|
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
|
||||||
|
noteObjID, _ := bson.ObjectIDFromHex(noteID)
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
|
||||||
|
note, err := h.noteService.GetNote(r.Context(), noteObjID, spaceObjID, userObjID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotesBySpace retrieves notes in a space
|
||||||
|
func (h *NoteHandler) GetNotesBySpace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceID := mux.Vars(r)["spaceId"]
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
skip, _ := strconv.Atoi(r.URL.Query().Get("skip"))
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
if limit == 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
|
||||||
|
notes, err := h.noteService.GetNotesBySpace(r.Context(), spaceObjID, userObjID, skip, limit)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchNotes performs full-text search
|
||||||
|
func (h *NoteHandler) SearchNotes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceID := mux.Vars(r)["spaceId"]
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
http.Error(w, "Missing search query", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
|
||||||
|
notes, err := h.noteService.SearchNotes(r.Context(), spaceObjID, userObjID, query)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNote updates a note
|
||||||
|
func (h *NoteHandler) UpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPut {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
spaceID := vars["spaceId"]
|
||||||
|
noteID := vars["noteId"]
|
||||||
|
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdateNoteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
|
||||||
|
noteObjID, _ := bson.ObjectIDFromHex(noteID)
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
|
||||||
|
note, err := h.noteService.UpdateNote(r.Context(), noteObjID, spaceObjID, userObjID, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNote deletes a note
|
||||||
|
func (h *NoteHandler) DeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
spaceID := vars["spaceId"]
|
||||||
|
noteID := vars["noteId"]
|
||||||
|
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
|
||||||
|
noteObjID, _ := bson.ObjectIDFromHex(noteID)
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
|
||||||
|
if err := h.noteService.DeleteNote(r.Context(), noteObjID, spaceObjID, userObjID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockNote verifies a note password and returns full note content
|
||||||
|
func (h *NoteHandler) UnlockNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
spaceID := vars["spaceId"]
|
||||||
|
noteID := vars["noteId"]
|
||||||
|
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UnlockNoteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
|
||||||
|
noteObjID, _ := bson.ObjectIDFromHex(noteID)
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
|
||||||
|
note, err := h.noteService.UnlockNote(r.Context(), noteObjID, spaceObjID, userObjID, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "invalid note password" {
|
||||||
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(note)
|
||||||
|
}
|
||||||
156
backend/internal/interfaces/handlers/public_handler.go
Normal file
156
backend/internal/interfaces/handlers/public_handler.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/application/dto"
|
||||||
|
"github.com/noteapp/backend/internal/application/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublicHandler handles unauthenticated public read-only requests
|
||||||
|
type PublicHandler struct {
|
||||||
|
spaceService *services.SpaceService
|
||||||
|
noteService *services.NoteService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPublicHandler creates a new PublicHandler
|
||||||
|
func NewPublicHandler(spaceService *services.SpaceService, noteService *services.NoteService) *PublicHandler {
|
||||||
|
return &PublicHandler{spaceService: spaceService, noteService: noteService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicSpace handles GET /public/spaces/{spaceId}
|
||||||
|
func (h *PublicHandler) GetPublicSpace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
space, err := h.spaceService.GetPublicSpace(r.Context(), spaceID)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "space is not public" {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(space)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublicSpaces handles GET /public/spaces
|
||||||
|
func (h *PublicHandler) ListPublicSpaces(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaces, err := h.spaceService.GetPublicSpaces(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"spaces": spaces})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicNotes handles GET /public/spaces/{spaceId}/notes
|
||||||
|
func (h *PublicHandler) GetPublicNotes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
skip := 0
|
||||||
|
limit := 50
|
||||||
|
if v := r.URL.Query().Get("skip"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||||
|
skip = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notes, err := h.noteService.GetPublicNotesBySpace(r.Context(), spaceID, skip, limit)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "space is not public" {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"notes": notes})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicNote handles GET /public/spaces/{spaceId}/notes/{noteId}
|
||||||
|
func (h *PublicHandler) GetPublicNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noteID, err := bson.ObjectIDFromHex(vars["noteId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid note id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
note, err := h.noteService.GetPublicNoteBySpaceAndID(r.Context(), spaceID, noteID)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "space is not public" || err.Error() == "note is not public" || err.Error() == "space not found" || err.Error() == "note not found" {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockPublicNote verifies a public note password and returns full note content
|
||||||
|
func (h *PublicHandler) UnlockPublicNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(vars["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noteID, err := bson.ObjectIDFromHex(vars["noteId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid note id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UnlockNoteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
note, err := h.noteService.UnlockPublicNote(r.Context(), spaceID, noteID, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "invalid note password" {
|
||||||
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err.Error() == "space is not public" || err.Error() == "space not found" || err.Error() == "note not found" {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(note)
|
||||||
|
}
|
||||||
30
backend/internal/interfaces/handlers/settings_handler.go
Normal file
30
backend/internal/interfaces/handlers/settings_handler.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/application/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingsHandler handles public app settings endpoints.
|
||||||
|
type SettingsHandler struct {
|
||||||
|
authService *services.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSettingsHandler creates a new settings handler.
|
||||||
|
func NewSettingsHandler(authService *services.AuthService) *SettingsHandler {
|
||||||
|
return &SettingsHandler{authService: authService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeatureFlags handles GET /api/v1/settings/feature-flags.
|
||||||
|
func (h *SettingsHandler) GetFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flags, err := h.authService.GetFeatureFlags(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(flags)
|
||||||
|
}
|
||||||
295
backend/internal/interfaces/handlers/space_handler.go
Normal file
295
backend/internal/interfaces/handlers/space_handler.go
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/noteapp/backend/internal/application/dto"
|
||||||
|
"github.com/noteapp/backend/internal/application/services"
|
||||||
|
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SpaceHandler handles space endpoints
|
||||||
|
type SpaceHandler struct {
|
||||||
|
spaceService *services.SpaceService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSpaceHandler creates a new space handler
|
||||||
|
func NewSpaceHandler(spaceService *services.SpaceService) *SpaceHandler {
|
||||||
|
return &SpaceHandler{
|
||||||
|
spaceService: spaceService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSpace creates a new space
|
||||||
|
func (h *SpaceHandler) CreateSpace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.CreateSpaceRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
space, err := h.spaceService.CreateSpace(r.Context(), userObjID, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(space)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserSpaces retrieves all spaces for the user
|
||||||
|
func (h *SpaceHandler) GetUserSpaces(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
spaces, err := h.spaceService.GetUserSpaces(r.Context(), userObjID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(spaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpace retrieves a space
|
||||||
|
func (h *SpaceHandler) GetSpace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceID := mux.Vars(r)["spaceId"]
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
|
||||||
|
space, err := h.spaceService.GetSpaceByID(r.Context(), spaceObjID, userObjID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(space)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSpace updates a space
|
||||||
|
func (h *SpaceHandler) UpdateSpace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPut {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceID := mux.Vars(r)["spaceId"]
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.CreateSpaceRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
|
||||||
|
space, err := h.spaceService.UpdateSpace(r.Context(), spaceObjID, userObjID, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(space)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSpace deletes a space
|
||||||
|
func (h *SpaceHandler) DeleteSpace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceID := mux.Vars(r)["spaceId"]
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
|
||||||
|
if err := h.spaceService.DeleteSpace(r.Context(), spaceObjID, userObjID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpaceMembers retrieves all members in a space (owner only)
|
||||||
|
func (h *SpaceHandler) GetSpaceMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceID := mux.Vars(r)["spaceId"]
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
|
||||||
|
members, err := h.spaceService.GetSpaceMembers(r.Context(), spaceObjID, userObjID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"members": members})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMember adds a member to a space (owner/editor)
|
||||||
|
func (h *SpaceHandler) AddMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceID := mux.Vars(r)["spaceId"]
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.AddSpaceMemberRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
targetUserObjID, err := bson.ObjectIDFromHex(req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid user id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.spaceService.AddMember(r.Context(), spaceObjID, userObjID, targetUserObjID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"message": "member added"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMember removes a member from a space (owner/editor)
|
||||||
|
func (h *SpaceHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceID := mux.Vars(r)["spaceId"]
|
||||||
|
targetUserID := mux.Vars(r)["userId"]
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, err := bson.ObjectIDFromHex(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid space id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userObjID, err := bson.ObjectIDFromHex(userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid user id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetUserObjID, err := bson.ObjectIDFromHex(targetUserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid target user id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.spaceService.RemoveMember(r.Context(), spaceObjID, userObjID, targetUserObjID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailableUsers returns user options for member selection (owner only)
|
||||||
|
func (h *SpaceHandler) GetAvailableUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceID := mux.Vars(r)["spaceId"]
|
||||||
|
userID, err := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceObjID, _ := bson.ObjectIDFromHex(spaceID)
|
||||||
|
userObjID, _ := bson.ObjectIDFromHex(userID)
|
||||||
|
|
||||||
|
users, err := h.spaceService.ListAvailableUsers(r.Context(), spaceObjID, userObjID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
|
||||||
|
}
|
||||||
91
backend/internal/interfaces/middleware/auth.go
Normal file
91
backend/internal/interfaces/middleware/auth.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContextKey is a custom type for context keys
|
||||||
|
type ContextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserIDKey ContextKey = "user_id"
|
||||||
|
EmailKey ContextKey = "email"
|
||||||
|
UserKey ContextKey = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMiddleware verifies JWT tokens
|
||||||
|
type AuthMiddleware struct {
|
||||||
|
jwtManager *auth.JWTManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthMiddleware creates a new auth middleware
|
||||||
|
func NewAuthMiddleware(jwtManager *auth.JWTManager) *AuthMiddleware {
|
||||||
|
return &AuthMiddleware{
|
||||||
|
jwtManager: jwtManager,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware wraps an HTTP handler with authentication
|
||||||
|
func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Skip auth for login and register endpoints
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/auth/login") ||
|
||||||
|
strings.HasSuffix(r.URL.Path, "/auth/register") ||
|
||||||
|
strings.HasSuffix(r.URL.Path, "/health") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract token from Authorization header
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
http.Error(w, "Invalid authorization header format", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := parts[1]
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
claims, err := m.jwtManager.VerifyAccessToken(token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid token: "+err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add claims to context
|
||||||
|
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
|
||||||
|
ctx = context.WithValue(ctx, EmailKey, claims.Email)
|
||||||
|
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserIDFromContext extracts user ID from context
|
||||||
|
func GetUserIDFromContext(ctx context.Context) (string, error) {
|
||||||
|
userID, ok := ctx.Value(UserIDKey).(string)
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("user ID not found in context")
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEmailFromContext extracts email from context
|
||||||
|
func GetEmailFromContext(ctx context.Context) (string, error) {
|
||||||
|
email, ok := ctx.Value(EmailKey).(string)
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("email not found in context")
|
||||||
|
}
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
91
backend/internal/interfaces/middleware/security.go
Normal file
91
backend/internal/interfaces/middleware/security.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecurityHeaders adds security headers to responses
|
||||||
|
func SecurityHeaders(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// HSTS - HTTP Strict Transport Security
|
||||||
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
|
|
||||||
|
// CSRF Protection - same-site cookies
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|
||||||
|
// XSS Protection
|
||||||
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||||
|
|
||||||
|
// Clickjacking protection
|
||||||
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
|
||||||
|
// CSP - Content Security Policy
|
||||||
|
w.Header().Set("Content-Security-Policy",
|
||||||
|
"default-src 'self'; "+
|
||||||
|
"script-src 'self' 'unsafe-inline'; "+
|
||||||
|
"style-src 'self' 'unsafe-inline'; "+
|
||||||
|
"img-src 'self' data: https:; "+
|
||||||
|
"font-src 'self'; "+
|
||||||
|
"connect-src 'self'; "+
|
||||||
|
"frame-ancestors 'none'")
|
||||||
|
|
||||||
|
// Referrer Policy
|
||||||
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitMiddleware implements basic rate limiting
|
||||||
|
type RateLimitMiddleware struct {
|
||||||
|
// In production, use a proper rate limiter like github.com/go-chi/chi/middleware
|
||||||
|
// This is a placeholder for demonstration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimitMiddleware creates a new rate limit middleware
|
||||||
|
func NewRateLimitMiddleware() *RateLimitMiddleware {
|
||||||
|
return &RateLimitMiddleware{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware returns the rate limit middleware handler
|
||||||
|
func (m *RateLimitMiddleware) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// TODO: Implement proper rate limiting using distributed cache
|
||||||
|
// For now, this is a placeholder
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoggingMiddleware logs HTTP requests
|
||||||
|
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Printf("[%s] %s %s\n", r.Method, r.RequestURI, r.RemoteAddr)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORSMiddleware enables CORS
|
||||||
|
func CORSMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
switch {
|
||||||
|
case origin == "http://localhost", origin == "http://localhost:5173", strings.HasPrefix(origin, "http://127.0.0.1:"):
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Vary", "Origin")
|
||||||
|
default:
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "http://localhost")
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "600")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
55
backend/tests/integration/integration_test.go
Normal file
55
backend/tests/integration/integration_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/infrastructure/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDatabaseConnection tests MongoDB connection
|
||||||
|
func TestDatabaseConnection(t *testing.T) {
|
||||||
|
mongoURL := "mongodb://admin:password@localhost:27017/noteapp?authSource=admin"
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
db, err := database.NewDatabase(ctx, mongoURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close(ctx)
|
||||||
|
|
||||||
|
t.Log("✓ Successfully connected to MongoDB")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAPIHealth tests the health check endpoint
|
||||||
|
func TestAPIHealth(t *testing.T) {
|
||||||
|
resp, err := http.Get("http://localhost:8080/health")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test - server not running: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Health check passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthenticationFlow does an integration test of auth flow
|
||||||
|
func TestAuthenticationFlow(t *testing.T) {
|
||||||
|
log.Println("Integration test: Authentication flow")
|
||||||
|
log.Println("1. Register new user")
|
||||||
|
log.Println("2. Login with credentials")
|
||||||
|
log.Println("3. Use access token to access protected endpoint")
|
||||||
|
log.Println("4. Refresh access token")
|
||||||
|
log.Println("5. Logout")
|
||||||
|
|
||||||
|
fmt.Println("\nTo run: cd backend && go test ./tests/integration/...")
|
||||||
|
}
|
||||||
185
backend/tests/unit/auth_service_test.go
Normal file
185
backend/tests/unit/auth_service_test.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package unit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/noteapp/backend/internal/application/dto"
|
||||||
|
"github.com/noteapp/backend/internal/application/services"
|
||||||
|
"github.com/noteapp/backend/internal/domain/entities"
|
||||||
|
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||||
|
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockUserRepository is a mock for testing
|
||||||
|
type MockUserRepository struct {
|
||||||
|
users map[string]*entities.User
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockUserRepository() *MockUserRepository {
|
||||||
|
return &MockUserRepository{
|
||||||
|
users: make(map[string]*entities.User),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepository) CreateUser(ctx context.Context, user *entities.User) error {
|
||||||
|
m.users[user.Email] = user
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepository) GetUserByID(ctx context.Context, id bson.ObjectID) (*entities.User, error) {
|
||||||
|
for _, u := range m.users {
|
||||||
|
if u.Email != "" {
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepository) GetUserByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||||
|
if user, ok := m.users[email]; ok {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepository) GetUserByUsername(ctx context.Context, username string) (*entities.User, error) {
|
||||||
|
// Simplified mock
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepository) UpdateUser(ctx context.Context, user *entities.User) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepository) DeleteUser(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepository) ListAllUsers(ctx context.Context) ([]*entities.User, error) {
|
||||||
|
users := make([]*entities.User, 0, len(m.users))
|
||||||
|
for _, user := range m.users {
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterUser tests user registration
|
||||||
|
func TestRegisterUser(t *testing.T) {
|
||||||
|
mockRepo := NewMockUserRepository()
|
||||||
|
jwtManager := auth.NewJWTManager("test-secret-key", "noteapp", 0)
|
||||||
|
passHasher := security.NewPasswordHasher()
|
||||||
|
encryptor, _ := security.NewEncryptor("00000000000000000000000000000000")
|
||||||
|
|
||||||
|
authService := services.NewAuthService(
|
||||||
|
mockRepo,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
jwtManager,
|
||||||
|
passHasher,
|
||||||
|
encryptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := &dto.RegisterRequest{
|
||||||
|
Email: "test@example.com",
|
||||||
|
Username: "testuser",
|
||||||
|
Password: "SecurePassword123",
|
||||||
|
PasswordConfirm: "SecurePassword123",
|
||||||
|
FirstName: "Test",
|
||||||
|
LastName: "User",
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := authService.Register(context.Background(), req)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response == nil {
|
||||||
|
t.Fatal("Expected response, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.AccessToken == "" {
|
||||||
|
t.Fatal("Expected access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.User.Email != req.Email {
|
||||||
|
t.Fatalf("Expected email %s, got %s", req.Email, response.User.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasswordHashing tests password hashing and verification
|
||||||
|
func TestPasswordHashing(t *testing.T) {
|
||||||
|
hasher := security.NewPasswordHasher()
|
||||||
|
password := "MySecurePassword123"
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hash, err := hasher.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to hash password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify correct password
|
||||||
|
valid, err := hasher.VerifyPassword(password, hash)
|
||||||
|
if err != nil || !valid {
|
||||||
|
t.Fatal("Expected password verification to succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify wrong password
|
||||||
|
valid, err = hasher.VerifyPassword("WrongPassword", hash)
|
||||||
|
if err == nil || valid {
|
||||||
|
t.Fatal("Expected password verification to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJWTGeneration tests JWT token generation and verification
|
||||||
|
func TestJWTGeneration(t *testing.T) {
|
||||||
|
jwtManager := auth.NewJWTManager("test-secret-key", "noteapp", 0)
|
||||||
|
|
||||||
|
token, err := jwtManager.GenerateAccessToken("user123", "user@example.com", "testuser")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := jwtManager.VerifyAccessToken(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to verify token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.UserID != "user123" {
|
||||||
|
t.Fatalf("Expected user_id user123, got %s", claims.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Email != "user@example.com" {
|
||||||
|
t.Fatalf("Expected email user@example.com, got %s", claims.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEncryption tests encryption and decryption
|
||||||
|
func TestEncryption(t *testing.T) {
|
||||||
|
encryptor, err := security.NewEncryptor("00000000000000000000000000000000")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create encryptor: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := "sensitive-data-to-encrypt"
|
||||||
|
|
||||||
|
encrypted, err := encryptor.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := encryptor.Decrypt(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to decrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decrypted != plaintext {
|
||||||
|
t.Fatalf("Expected %s, got %s", plaintext, decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
devops/docker/Dockerfile
Normal file
42
devops/docker/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Frontend build stage
|
||||||
|
FROM node:25-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
ARG VITE_API_BASE_URL
|
||||||
|
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||||
|
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Backend build stage
|
||||||
|
FROM golang:1.26-alpine AS backend-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
COPY backend/go.mod backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
# Build the binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server/main.go
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /root/
|
||||||
|
|
||||||
|
COPY --from=backend-builder /app/server .
|
||||||
|
COPY --from=frontend-builder /frontend/dist ./public
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["./server"]
|
||||||
78
devops/docker/nginx.conf
Normal file
78
devops/docker/nginx.conf
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=general_limit:10m rate=50r/s;
|
||||||
|
|
||||||
|
# notely upstream
|
||||||
|
upstream notely {
|
||||||
|
server notely:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# API routes
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api_limit burst=20 nodelay;
|
||||||
|
|
||||||
|
proxy_pass http://notely;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://notely;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# All other routes (frontend) served by notely
|
||||||
|
location / {
|
||||||
|
limit_req zone=general_limit burst=50 nodelay;
|
||||||
|
|
||||||
|
proxy_pass http://notely;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
240
devops/kubernetes/deployment.yaml
Normal file
240
devops/kubernetes/deployment.yaml
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: noteapp
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: mongodb-pvc
|
||||||
|
namespace: noteapp
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 10Gi
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: app-config
|
||||||
|
namespace: noteapp
|
||||||
|
data:
|
||||||
|
MONGODB_URI: "mongodb://admin:password@mongodb:27017/noteapp?authSource=admin"
|
||||||
|
JWT_SECRET: "your-super-secret-jwt-key-change-in-production"
|
||||||
|
ENCRYPTION_KEY: "00000000000000000000000000000000"
|
||||||
|
PORT: "8080"
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: mongodb-credentials
|
||||||
|
namespace: noteapp
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
username: admin
|
||||||
|
password: password
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: mongodb
|
||||||
|
namespace: noteapp
|
||||||
|
spec:
|
||||||
|
serviceName: mongodb
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: mongodb
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: mongodb
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: mongodb
|
||||||
|
image: mongo:7.0-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 27017
|
||||||
|
name: mongodb
|
||||||
|
env:
|
||||||
|
- name: MONGO_INITDB_ROOT_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: mongodb-credentials
|
||||||
|
key: username
|
||||||
|
- name: MONGO_INITDB_ROOT_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: mongodb-credentials
|
||||||
|
key: password
|
||||||
|
volumeMounts:
|
||||||
|
- name: mongodb-storage
|
||||||
|
mountPath: /data/db
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- mongosh
|
||||||
|
- mongodb://admin:password@localhost:27017/admin?authSource=admin
|
||||||
|
- --quiet
|
||||||
|
- --eval
|
||||||
|
- db.adminCommand('ping').ok
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- mongosh
|
||||||
|
- mongodb://admin:password@localhost:27017/admin?authSource=admin
|
||||||
|
- --quiet
|
||||||
|
- --eval
|
||||||
|
- db.adminCommand('ping').ok
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
volumes:
|
||||||
|
- name: mongodb-storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: mongodb-pvc
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: mongodb
|
||||||
|
namespace: noteapp
|
||||||
|
spec:
|
||||||
|
clusterIP: None
|
||||||
|
ports:
|
||||||
|
- port: 27017
|
||||||
|
targetPort: 27017
|
||||||
|
selector:
|
||||||
|
app: mongodb
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: noteapp
|
||||||
|
namespace: noteapp
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: noteapp
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: noteapp
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: noteapp
|
||||||
|
image: noteapp:latest
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: http
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: app-config
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: noteapp
|
||||||
|
namespace: noteapp
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: noteapp
|
||||||
|
ports:
|
||||||
|
- port: 8080
|
||||||
|
targetPort: 8080
|
||||||
|
protocol: TCP
|
||||||
|
type: ClusterIP
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: noteapp-ingress
|
||||||
|
namespace: noteapp
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "false"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
rules:
|
||||||
|
- host: noteapp.local
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /api
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: noteapp
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: noteapp
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: autoscaling/v2
|
||||||
|
kind: HorizontalPodAutoscaler
|
||||||
|
metadata:
|
||||||
|
name: noteapp-hpa
|
||||||
|
namespace: noteapp
|
||||||
|
spec:
|
||||||
|
scaleTargetRef:
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: noteapp
|
||||||
|
minReplicas: 2
|
||||||
|
maxReplicas: 10
|
||||||
|
metrics:
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: 70
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: memory
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: 80
|
||||||
68
docker-compose.yml
Normal file
68
docker-compose.yml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
mongodb:
|
||||||
|
image: mongo:8.0
|
||||||
|
container_name: notely-mongodb
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_DATABASE: notely
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: admin
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: password
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
volumes:
|
||||||
|
- mongodb_data:/data/db
|
||||||
|
- mongodb_config:/data/configdb
|
||||||
|
networks:
|
||||||
|
- notely-network
|
||||||
|
healthcheck:
|
||||||
|
test: mongosh "mongodb://admin:password@localhost:27017/admin?authSource=admin" --quiet --eval "db.adminCommand('ping').ok"
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
notely:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./devops/docker/Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
|
||||||
|
container_name: notely-app
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT}:${BACKEND_PORT}"
|
||||||
|
environment:
|
||||||
|
MONGODB_URI: ${MONGODB_URI}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
|
PORT: ${BACKEND_PORT}
|
||||||
|
FRONTEND_URL: ${FRONTEND_URL}
|
||||||
|
DEFAULT_ADMIN_EMAIL: ${DEFAULT_ADMIN_EMAIL}
|
||||||
|
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME}
|
||||||
|
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD}
|
||||||
|
depends_on:
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- notely-network
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:1.25-alpine
|
||||||
|
container_name: notely-nginx
|
||||||
|
ports:
|
||||||
|
- "${NGINX_HTTP_PORT}:80"
|
||||||
|
- "${NGINX_HTTPS_PORT}:443"
|
||||||
|
volumes:
|
||||||
|
- ./devops/docker/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./devops/docker/ssl:/etc/nginx/ssl:ro
|
||||||
|
depends_on:
|
||||||
|
- notely
|
||||||
|
networks:
|
||||||
|
- notely-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongodb_data:
|
||||||
|
mongodb_config:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
notely-network:
|
||||||
|
driver: bridge
|
||||||
10
frontend/.env.example
Normal file
10
frontend/.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Frontend Environment Example
|
||||||
|
|
||||||
|
# API Base URL (Backend server)
|
||||||
|
VITE_API_BASE_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
VITE_ENV=development
|
||||||
|
|
||||||
|
# Feature Flags
|
||||||
|
VITE_ENABLE_ANALYTICS=false
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Notely</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2502
frontend/package-lock.json
generated
Normal file
2502
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "noteapp-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mdi/font": "^7.4.47",
|
||||||
|
"@mdi/js": "^7.2.0",
|
||||||
|
"axios": "^1.4.0",
|
||||||
|
"bootstrap": "^5.3.0",
|
||||||
|
"dompurify": "^3.0.0",
|
||||||
|
"marked": "^9.0.0",
|
||||||
|
"pinia": "^2.1.0",
|
||||||
|
"vue": "^3.3.0",
|
||||||
|
"vue-router": "^4.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^4.2.0",
|
||||||
|
"@vue/test-utils": "^2.4.0",
|
||||||
|
"vite": "^4.3.0",
|
||||||
|
"vitest": "^0.34.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1081
frontend/src/App.vue
Normal file
1081
frontend/src/App.vue
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/src/assets/styles/main.css
Normal file
45
frontend/src/assets/styles/main.css
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: #667eea;
|
||||||
|
--secondary-color: #764ba2;
|
||||||
|
--text-color: #333;
|
||||||
|
--bg-color: #f8f9fa;
|
||||||
|
--border-color: #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
254
frontend/src/components/AdminSpaceModal.vue
Normal file
254
frontend/src/components/AdminSpaceModal.vue
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Edit Space</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input v-model="form.name" type="text" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input v-model="form.description" type="text" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Icon</label>
|
||||||
|
<input v-model="form.icon" type="text" class="form-control" maxlength="20" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 d-flex justify-content-between align-items-center">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input id="admin-space-public" v-model="form.is_public" class="form-check-input" type="checkbox" />
|
||||||
|
<label for="admin-space-public" class="form-check-label">Public space</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" :disabled="savingSpace" @click="saveSpace">
|
||||||
|
{{ savingSpace ? "Saving..." : "Save Space" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-3 mb-2">
|
||||||
|
<h6 class="mb-0">Members</h6>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingMembers" @click="loadMembers">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="row g-2 align-items-end mb-3" @submit.prevent="addMember">
|
||||||
|
<div class="col-md-10">
|
||||||
|
<label class="form-label form-label-sm mb-1">Username</label>
|
||||||
|
<select v-model="newMember.user_id" class="form-select form-select-sm" required>
|
||||||
|
<option disabled value="">Select user</option>
|
||||||
|
<option v-for="u in selectableUsers" :key="u.id" :value="u.id">{{ u.username }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm w-100" :disabled="addingMember">
|
||||||
|
{{ addingMember ? "..." : "Add" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-if="loadingMembers" class="text-muted small">Loading members...</div>
|
||||||
|
<div v-else-if="members.length === 0" class="text-muted small">No members found.</div>
|
||||||
|
|
||||||
|
<div v-else class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Joined</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="m in members" :key="m.user_id">
|
||||||
|
<td>{{ m.username || m.user_id }}</td>
|
||||||
|
<td class="small text-muted">{{ formatDate(m.joined_at) }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-danger" :disabled="removingMemberId === m.user_id" @click="removeMember(m)">
|
||||||
|
{{ removingMemberId === m.user_id ? "Removing..." : "Remove" }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
|
||||||
|
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<div class="border border-danger rounded p-3 mt-3">
|
||||||
|
<h6 class="text-danger mb-1">Danger Zone</h6>
|
||||||
|
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p>
|
||||||
|
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace">
|
||||||
|
{{ deleting ? "Deleting..." : "Delete Space" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show"></div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import apiClient from "../services/apiClient";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
space: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "saved", "deleted"]);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
icon: "",
|
||||||
|
is_public: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = ref([]);
|
||||||
|
const loadingMembers = ref(false);
|
||||||
|
const savingSpace = ref(false);
|
||||||
|
const addingMember = ref(false);
|
||||||
|
const removingMemberId = ref("");
|
||||||
|
const error = ref("");
|
||||||
|
const success = ref("");
|
||||||
|
const newMember = ref({ user_id: "" });
|
||||||
|
const deleting = ref(false);
|
||||||
|
|
||||||
|
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
|
||||||
|
|
||||||
|
const clearMessages = () => {
|
||||||
|
error.value = "";
|
||||||
|
success.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFormFromSpace = () => {
|
||||||
|
form.value = {
|
||||||
|
name: props.space?.name || "",
|
||||||
|
description: props.space?.description || "",
|
||||||
|
icon: props.space?.icon || "",
|
||||||
|
is_public: !!props.space?.is_public,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectableUsers = computed(() => {
|
||||||
|
const memberIds = new Set(members.value.map((m) => m.user_id));
|
||||||
|
return (props.users || []).filter((u) => !memberIds.has(u.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadMembers = async () => {
|
||||||
|
loadingMembers.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/api/v1/admin/spaces/${props.space.id}/members`);
|
||||||
|
members.value = response.data.members || [];
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to load members.";
|
||||||
|
} finally {
|
||||||
|
loadingMembers.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSpace = async () => {
|
||||||
|
savingSpace.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/api/v1/admin/spaces/${props.space.id}`, {
|
||||||
|
name: form.value.name,
|
||||||
|
description: form.value.description,
|
||||||
|
icon: form.value.icon,
|
||||||
|
is_public: form.value.is_public,
|
||||||
|
});
|
||||||
|
success.value = "Space updated.";
|
||||||
|
emit("saved", response.data);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to update space.";
|
||||||
|
} finally {
|
||||||
|
savingSpace.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMember = async () => {
|
||||||
|
if (!newMember.value.user_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addingMember.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/api/v1/admin/spaces/${props.space.id}/members`, {
|
||||||
|
user_id: newMember.value.user_id,
|
||||||
|
});
|
||||||
|
success.value = "Member added.";
|
||||||
|
newMember.value = { user_id: "" };
|
||||||
|
await loadMembers();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to add member.";
|
||||||
|
} finally {
|
||||||
|
addingMember.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMember = async (member) => {
|
||||||
|
const memberName = member?.username || member?.user_id;
|
||||||
|
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removingMemberId.value = member.user_id;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/v1/admin/spaces/${props.space.id}/members/${member.user_id}`);
|
||||||
|
success.value = "Member removed.";
|
||||||
|
await loadMembers();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to remove member.";
|
||||||
|
} finally {
|
||||||
|
removingMemberId.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.space,
|
||||||
|
async () => {
|
||||||
|
resetFormFromSpace();
|
||||||
|
await loadMembers();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteSpace = async () => {
|
||||||
|
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleting.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/v1/admin/spaces/${props.space.id}`);
|
||||||
|
emit("deleted", props.space);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to delete space.";
|
||||||
|
} finally {
|
||||||
|
deleting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
278
frontend/src/components/CategoryTree.vue
Normal file
278
frontend/src/components/CategoryTree.vue
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<template>
|
||||||
|
<div class="category-tree">
|
||||||
|
<div v-for="category in categories" :key="category.id" class="category-item">
|
||||||
|
<div class="category-header" @click="handleCategoryClick(category)">
|
||||||
|
<span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length">
|
||||||
|
<i :class="expandedCategories[category.id] ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
<span v-else class="expand-icon"> </span>
|
||||||
|
<span class="category-name">{{ category.name }}</span>
|
||||||
|
<div v-if="canAnyCategoryAction" class="category-actions">
|
||||||
|
<button class="menu-button" type="button" @click.stop="toggleMenu(category.id)">
|
||||||
|
<i class="mdi mdi-dots-horizontal" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<div v-if="openMenuId === category.id" class="menu-dropdown">
|
||||||
|
<button v-if="canCreateCategories" type="button" class="menu-item" @click.stop="handleAddSubcategory(category)">Add subcategory</button>
|
||||||
|
<button v-if="canEditCategories" type="button" class="menu-item" @click.stop="handleEditCategory(category)">Edit</button>
|
||||||
|
<button v-if="canDeleteCategories" type="button" class="menu-item danger" @click.stop="handleDeleteCategory(category)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="expandedCategories[category.id]" class="category-content">
|
||||||
|
<div
|
||||||
|
v-for="note in sortedNotes(category.notes)"
|
||||||
|
:key="note.id"
|
||||||
|
class="note-item"
|
||||||
|
:class="{ 'is-featured': note.is_favorite || note.is_featured, 'is-pinned': note.is_pinned }"
|
||||||
|
@click.stop="onSelectNote(note)"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-file-document-outline me-1" aria-hidden="true"></i>
|
||||||
|
<span>{{ note.title }}</span>
|
||||||
|
<i v-if="note.is_pinned" class="mdi mdi-pin pin-icon me-1" aria-hidden="true"></i>
|
||||||
|
<i v-else-if="note.is_favorite || note.is_featured" class="mdi mdi-star featured-icon me-1" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CategoryTree
|
||||||
|
v-if="category.subcategories?.length"
|
||||||
|
:categories="category.subcategories"
|
||||||
|
:on-select-note="onSelectNote"
|
||||||
|
:on-select-category="onSelectCategory"
|
||||||
|
:on-add-subcategory="onAddSubcategory"
|
||||||
|
:on-edit-category="onEditCategory"
|
||||||
|
:on-delete-category="onDeleteCategory"
|
||||||
|
:can-create-categories="canCreateCategories"
|
||||||
|
:can-edit-categories="canEditCategories"
|
||||||
|
:can-delete-categories="canDeleteCategories"
|
||||||
|
class="subcategories"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { sortNotesByPriority } from "../utils/noteSort";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
categories: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
onSelectNote: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
onSelectCategory: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
onAddSubcategory: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
onEditCategory: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
onDeleteCategory: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
canCreateCategories: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
canEditCategories: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
canDeleteCategories: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canAnyCategoryAction = computed(() => props.canCreateCategories || props.canEditCategories || props.canDeleteCategories);
|
||||||
|
|
||||||
|
const expandedCategories = ref({});
|
||||||
|
const openMenuId = ref(null);
|
||||||
|
|
||||||
|
const sortedNotes = (notes) => sortNotesByPriority(notes || []);
|
||||||
|
|
||||||
|
const toggleCategory = (categoryId) => {
|
||||||
|
expandedCategories.value[categoryId] = !expandedCategories.value[categoryId];
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMenu = (categoryId) => {
|
||||||
|
openMenuId.value = openMenuId.value === categoryId ? null : categoryId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryClick = (category) => {
|
||||||
|
props.onSelectCategory(category);
|
||||||
|
toggleCategory(category.id);
|
||||||
|
openMenuId.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSubcategory = (category) => {
|
||||||
|
if (!props.canCreateCategories) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openMenuId.value = null;
|
||||||
|
expandedCategories.value[category.id] = true;
|
||||||
|
props.onAddSubcategory(category);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCategory = (category) => {
|
||||||
|
if (!props.canEditCategories) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openMenuId.value = null;
|
||||||
|
props.onEditCategory(category);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCategory = (category) => {
|
||||||
|
if (!props.canDeleteCategories) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openMenuId.value = null;
|
||||||
|
props.onDeleteCategory(category);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.category-item {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header:hover {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-actions {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 30px;
|
||||||
|
right: 0;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 0.35rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.15);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background-color: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item.danger {
|
||||||
|
color: #c92a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-content {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item:hover {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item span {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-icon {
|
||||||
|
color: #408aca;
|
||||||
|
font-size: 0.9em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-icon {
|
||||||
|
color: #f08c00;
|
||||||
|
font-size: 0.9em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item.is-pinned {
|
||||||
|
background: #dbf5ff;
|
||||||
|
border: 1px solid #a8d1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item.is-pinned:hover {
|
||||||
|
background: #c5e9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item.is-featured {
|
||||||
|
background: #fff9db;
|
||||||
|
border: 1px solid #ffd8a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item.is-featured:hover {
|
||||||
|
background: #fff6c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subcategories {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
93
frontend/src/components/CreateCategoryModal.vue
Normal file
93
frontend/src/components/CreateCategoryModal.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeModal">
|
||||||
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ isEditing ? "Edit Category" : "Create New Category" }}</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="closeModal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="catName" class="form-label">Category Name</label>
|
||||||
|
<input id="catName" v-model="form.name" type="text" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="catDesc" class="form-label">Description</label>
|
||||||
|
<textarea id="catDesc" v-model="form.description" class="form-control" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="catParent" class="form-label">Parent Category</label>
|
||||||
|
<select id="catParent" v-model="form.parent_id" class="form-select">
|
||||||
|
<option :value="null">No parent</option>
|
||||||
|
<option v-for="category in parentOptions" :key="category.id" :value="category.id">
|
||||||
|
{{ category.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">{{ isEditing ? "Save" : "Create" }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show"></div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
category: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
parentOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
parentId: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "submit"]);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
parent_id: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.category);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.category, props.parentId],
|
||||||
|
() => {
|
||||||
|
form.value = {
|
||||||
|
name: props.category?.name || "",
|
||||||
|
description: props.category?.description || "",
|
||||||
|
parent_id: props.category?.parent_id ?? props.parentId ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (form.value.name.trim()) {
|
||||||
|
emit("submit", {
|
||||||
|
...form.value,
|
||||||
|
parent_id: form.value.parent_id || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
156
frontend/src/components/CreateNoteModal.vue
Normal file
156
frontend/src/components/CreateNoteModal.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeModal">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Create New Note</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="closeModal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form @submit.prevent="handleCreate">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="noteTitle" class="form-label">Note Title</label>
|
||||||
|
<input id="noteTitle" v-model="form.title" type="text" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="noteContent" class="form-label">Content</label>
|
||||||
|
<textarea id="noteContent" v-model="form.content" class="form-control" rows="5"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="noteDescription" class="form-label">Description</label>
|
||||||
|
<textarea id="noteDescription" v-model="form.description" class="form-control" rows="2" maxlength="500" placeholder="Short summary shown in note lists"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="noteCategory" class="form-label">Category</label>
|
||||||
|
<select id="noteCategory" v-model="form.category_id" class="form-select">
|
||||||
|
<option :value="null">Uncategorized</option>
|
||||||
|
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
|
||||||
|
{{ category.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="note-flags mb-3">
|
||||||
|
<label class="form-check flag-check">
|
||||||
|
<input v-model="form.is_pinned" class="form-check-input" type="checkbox" />
|
||||||
|
<span class="form-check-label">Pinned</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-check flag-check">
|
||||||
|
<input v-model="form.is_favorite" class="form-check-input" type="checkbox" />
|
||||||
|
<span class="form-check-label">Featured</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label v-if="publicSharingEnabled" class="form-check flag-check">
|
||||||
|
<input v-model="form.is_public" class="form-check-input" type="checkbox" />
|
||||||
|
<span class="form-check-label">Public</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-check flag-check">
|
||||||
|
<input v-model="form.is_password_protected" class="form-check-input" type="checkbox" />
|
||||||
|
<span class="form-check-label">Password Protected</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.is_password_protected" class="mb-3">
|
||||||
|
<label for="notePassword" class="form-label">Note Password</label>
|
||||||
|
<input id="notePassword" v-model="form.note_password" type="password" class="form-control" minlength="4" maxlength="128" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Create</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show"></div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
import { useSettingsStore } from "../stores/settingsStore";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
categoryOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
defaultCategoryId: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "create"]);
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const publicSharingEnabled = ref(true);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
content: "",
|
||||||
|
category_id: null,
|
||||||
|
is_pinned: false,
|
||||||
|
is_favorite: false,
|
||||||
|
is_public: false,
|
||||||
|
is_password_protected: false,
|
||||||
|
note_password: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.defaultCategoryId,
|
||||||
|
(defaultCategoryId) => {
|
||||||
|
form.value.category_id = defaultCategoryId || null;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await settingsStore.loadFeatureFlags();
|
||||||
|
publicSharingEnabled.value = settingsStore.publicSharingEnabled;
|
||||||
|
|
||||||
|
if (!publicSharingEnabled.value) {
|
||||||
|
form.value.is_public = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (form.value.title.trim()) {
|
||||||
|
const { is_password_protected, ...payload } = form.value;
|
||||||
|
emit("create", {
|
||||||
|
...payload,
|
||||||
|
category_id: payload.category_id || null,
|
||||||
|
note_password: is_password_protected ? payload.note_password || "" : "",
|
||||||
|
});
|
||||||
|
form.value = {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
content: "",
|
||||||
|
category_id: props.defaultCategoryId || null,
|
||||||
|
is_pinned: false,
|
||||||
|
is_favorite: false,
|
||||||
|
is_public: false,
|
||||||
|
is_password_protected: false,
|
||||||
|
note_password: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.note-flags {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
52
frontend/src/components/CreateSpaceModal.vue
Normal file
52
frontend/src/components/CreateSpaceModal.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeModal">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Create New Space</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="closeModal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form @submit.prevent="handleCreate">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="spaceName" class="form-label">Space Name</label>
|
||||||
|
<input id="spaceName" v-model="form.name" type="text" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="spaceDesc" class="form-label">Description</label>
|
||||||
|
<textarea id="spaceDesc" v-model="form.description" class="form-control" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Create</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show"></div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "create"]);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (form.value.name.trim()) {
|
||||||
|
emit("create", form.value);
|
||||||
|
form.value = { name: "", description: "" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
265
frontend/src/components/ManageAuthProvidersModal.vue
Normal file
265
frontend/src/components/ManageAuthProvidersModal.vue
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div class="modal-backdrop-custom" @click.self="$emit('close')">
|
||||||
|
<div class="modal-panel">
|
||||||
|
<div class="provider-modal-header">
|
||||||
|
<div>
|
||||||
|
<h5 class="provider-modal-title mb-1">Identity Providers</h5>
|
||||||
|
<p class="text-muted mb-0">Configure OAuth2 and OIDC buttons for the login page.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close provider-modal-close" @click="$emit('close')"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="provider-modal-body">
|
||||||
|
<div v-if="error" class="alert alert-danger">{{ error }}</div>
|
||||||
|
<div v-if="successMessage" class="alert alert-success">{{ successMessage }}</div>
|
||||||
|
|
||||||
|
<section class="provider-section">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="mb-0">Configured Providers</h6>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" :disabled="loading" @click="loadProviders">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-muted small">Loading providers...</div>
|
||||||
|
<div v-else-if="providers.length === 0" class="border rounded p-3 text-muted">No providers configured yet.</div>
|
||||||
|
<div v-else class="list-group">
|
||||||
|
<div v-for="provider in providers" :key="provider.id" class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">{{ provider.name }}</div>
|
||||||
|
<div class="small text-muted">{{ provider.type.toUpperCase() }} · {{ provider.scopes.join(", ") }}</div>
|
||||||
|
<div class="small text-muted">Callback: {{ buildCallbackUrl(provider.id) }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge" :class="provider.is_active ? 'text-bg-success' : 'text-bg-secondary'">
|
||||||
|
{{ provider.is_active ? "Active" : "Disabled" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="provider-section">
|
||||||
|
<h6 class="mb-3">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="form.name" type="text" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Provider Type</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</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</label>
|
||||||
|
<input v-model="form.client_secret" type="password" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Authorization URL</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</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 when id_token contains profile claims" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">ID Token Field</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>
|
||||||
|
|
||||||
|
<div class="col-12 form-check ms-2">
|
||||||
|
<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 class="col-12 d-flex justify-content-end gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="$emit('close')">Close</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||||
|
{{ submitting ? "Saving..." : "Add Provider" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import apiClient from "../services/apiClient";
|
||||||
|
|
||||||
|
defineEmits(["close"]);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const successMessage = ref("");
|
||||||
|
const providers = ref([]);
|
||||||
|
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 loadProviders = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/api/v1/auth/providers");
|
||||||
|
providers.value = response.data.providers || [];
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data || err.message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCallbackUrl = (providerId) => `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/callback`;
|
||||||
|
|
||||||
|
const createProvider = async () => {
|
||||||
|
submitting.value = true;
|
||||||
|
error.value = "";
|
||||||
|
successMessage.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.post("/api/v1/auth/providers", {
|
||||||
|
...form.value,
|
||||||
|
scopes: form.value.scopes
|
||||||
|
.split(",")
|
||||||
|
.map((scope) => scope.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
});
|
||||||
|
successMessage.value = "Provider added.";
|
||||||
|
resetForm();
|
||||||
|
await loadProviders();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.response?.data || err.message;
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(loadProviders);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-backdrop-custom {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.45);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: min(920px, 100%);
|
||||||
|
max-height: min(92vh, 980px);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dbe3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-modal-title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-modal-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-modal-body {
|
||||||
|
padding: 1rem 1.25rem 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-section {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-list {
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-backdrop-custom {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-modal-header,
|
||||||
|
.provider-modal-body {
|
||||||
|
padding-left: 0.85rem;
|
||||||
|
padding-right: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
287
frontend/src/components/NoteEditor.vue
Normal file
287
frontend/src/components/NoteEditor.vue
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<template>
|
||||||
|
<div class="note-editor">
|
||||||
|
<div class="editor-toolbar mb-3">
|
||||||
|
<button class="btn btn-sm btn-primary" @click="saveNote">Save</button>
|
||||||
|
<button v-if="canDelete" class="btn btn-sm btn-danger ms-2" @click="confirmDelete">Delete</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary ms-2" @click="emit('cancel')">Cancel</button>
|
||||||
|
<button class="btn btn-sm btn-secondary ms-2" @click="togglePreview">
|
||||||
|
{{ showPreview ? "Edit" : "Preview" }}
|
||||||
|
</button>
|
||||||
|
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-container">
|
||||||
|
<input v-model="editingNote.title" type="text" class="form-control form-control-lg mb-3" placeholder="Note title..." @input="autoSave" />
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<textarea v-model="editingNote.description" class="form-control" rows="2" maxlength="500" placeholder="Short summary shown in note lists..." @input="autoSave"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div :class="{ 'col-md-6': showPreview, 'col-12': !showPreview }">
|
||||||
|
<textarea v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showPreview" class="col-md-6">
|
||||||
|
<div class="preview-pane border rounded p-3">
|
||||||
|
<div v-html="renderedMarkdown"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="form-label">Tags</label>
|
||||||
|
<input v-model="tagsInput" type="text" class="form-control" placeholder="Add tags separated by commas" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<select v-model="editingNote.category_id" class="form-select" @change="autoSave">
|
||||||
|
<option :value="null">Uncategorized</option>
|
||||||
|
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
|
||||||
|
{{ category.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note-flags mt-3">
|
||||||
|
<label class="flag-check">
|
||||||
|
<input v-model="editingNote.is_pinned" class="flag-check-input" type="checkbox" @change="autoSave" />
|
||||||
|
<span class="flag-check-label">Pinned</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flag-check">
|
||||||
|
<input v-model="editingNote.is_favorite" class="flag-check-input" type="checkbox" @change="autoSave" />
|
||||||
|
<span class="flag-check-label">Featured</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label v-if="publicSharingEnabled" class="flag-check">
|
||||||
|
<input v-model="editingNote.is_public" class="flag-check-input" type="checkbox" @change="autoSave" />
|
||||||
|
<span class="flag-check-label">Public</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="form-label">Password Protection</label>
|
||||||
|
<select v-model="passwordAction" class="form-select">
|
||||||
|
<option value="keep">Keep current setting</option>
|
||||||
|
<option value="set">Set or change password</option>
|
||||||
|
<option value="remove">Remove password protection</option>
|
||||||
|
</select>
|
||||||
|
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onBeforeUnmount, onMounted } from "vue";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { useSettingsStore } from "../stores/settingsStore";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
note: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
categoryOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
canDelete: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["save", "delete", "cancel"]);
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const publicSharingEnabled = ref(true);
|
||||||
|
|
||||||
|
const editingNote = ref({ ...props.note });
|
||||||
|
const showPreview = ref(false);
|
||||||
|
const tagsInput = ref(props.note.tags?.join(", ") || "");
|
||||||
|
const passwordAction = ref("keep");
|
||||||
|
const notePassword = ref("");
|
||||||
|
const saveTimeout = ref(null);
|
||||||
|
const saveState = ref("saved");
|
||||||
|
const saveStateTimeout = ref(null);
|
||||||
|
|
||||||
|
const renderedMarkdown = computed(() => {
|
||||||
|
const html = marked.parse(editingNote.value.content || "");
|
||||||
|
return DOMPurify.sanitize(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveStatusLabel = computed(() => {
|
||||||
|
switch (saveState.value) {
|
||||||
|
case "dirty":
|
||||||
|
return "Unsaved changes";
|
||||||
|
case "saving":
|
||||||
|
return "Saving...";
|
||||||
|
case "saved":
|
||||||
|
default:
|
||||||
|
return "Saved";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.note,
|
||||||
|
(newNote) => {
|
||||||
|
editingNote.value = { ...newNote };
|
||||||
|
tagsInput.value = newNote.tags?.join(", ") || "";
|
||||||
|
passwordAction.value = "keep";
|
||||||
|
notePassword.value = "";
|
||||||
|
saveState.value = "saved";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(tagsInput, () => {
|
||||||
|
if (editingNote.value.id) {
|
||||||
|
autoSave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const markSavedSoon = () => {
|
||||||
|
clearTimeout(saveStateTimeout.value);
|
||||||
|
saveStateTimeout.value = setTimeout(() => {
|
||||||
|
saveState.value = "saved";
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveNote = () => {
|
||||||
|
if (passwordAction.value === "set") {
|
||||||
|
if (!notePassword.value.trim()) {
|
||||||
|
alert("Please enter a note password.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (notePassword.value.trim().length < 4) {
|
||||||
|
alert("Note password must be at least 4 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveState.value = "saving";
|
||||||
|
const note = {
|
||||||
|
...editingNote.value,
|
||||||
|
category_id: editingNote.value.category_id || null,
|
||||||
|
tags: tagsInput.value
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => t),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (passwordAction.value === "set") {
|
||||||
|
note.note_password = notePassword.value;
|
||||||
|
} else if (passwordAction.value === "remove") {
|
||||||
|
note.note_password = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("save", note);
|
||||||
|
if (passwordAction.value !== "keep") {
|
||||||
|
passwordAction.value = "keep";
|
||||||
|
notePassword.value = "";
|
||||||
|
}
|
||||||
|
markSavedSoon();
|
||||||
|
};
|
||||||
|
|
||||||
|
const autoSave = () => {
|
||||||
|
saveState.value = "dirty";
|
||||||
|
clearTimeout(saveTimeout.value);
|
||||||
|
saveTimeout.value = setTimeout(saveNote, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (!props.canDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (confirm("Are you sure you want to delete this note?")) {
|
||||||
|
emit("delete", editingNote.value.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePreview = () => {
|
||||||
|
showPreview.value = !showPreview.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(saveTimeout.value);
|
||||||
|
clearTimeout(saveStateTimeout.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await settingsStore.loadFeatureFlags();
|
||||||
|
publicSharingEnabled.value = settingsStore.publicSharingEnabled;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status.dirty {
|
||||||
|
color: #b26a00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status.saving {
|
||||||
|
color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status.saved {
|
||||||
|
color: #2b8a3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-textarea {
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
min-height: 400px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-flags {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-check-input {
|
||||||
|
margin: 0;
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-check-label {
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 600px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
221
frontend/src/components/NoteList.vue
Normal file
221
frontend/src/components/NoteList.vue
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<template>
|
||||||
|
<div class="note-list" :class="{ 'note-list--list': viewMode === 'list' }">
|
||||||
|
<div v-if="notes.length === 0" class="empty-notes-state" role="status" aria-live="polite">
|
||||||
|
<i class="mdi mdi-file-document-outline empty-notes-icon" aria-hidden="true"></i>
|
||||||
|
<h3 class="empty-notes-title">No Notes Yet</h3>
|
||||||
|
<p class="empty-notes-message">This space is empty for now. Create your first note to get started.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="note in notes" :key="note.id" class="note-card" :class="{ 'is-pinned': note.is_pinned, 'is-featured': note.is_favorite || note.is_featured }" @click="selectNote(note)">
|
||||||
|
<h5 class="note-title">
|
||||||
|
<i v-if="note.is_pinned" class="mdi mdi-pin pin-icon" aria-hidden="true"></i>
|
||||||
|
<i v-else-if="note.is_favorite || note.is_featured" class="mdi mdi-star featured-icon" aria-hidden="true"></i>
|
||||||
|
{{ note.title }}
|
||||||
|
</h5>
|
||||||
|
<p class="note-preview">{{ getDescription(note) }}</p>
|
||||||
|
<small class="text-muted">Updated: {{ formatDate(note.updated_at) }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="canLoadMore" class="list-footer">
|
||||||
|
<button class="btn btn-outline-secondary" :disabled="isLoadingMore" @click="emit('loadMore')">
|
||||||
|
{{ isLoadingMore ? "Loading..." : "Load more" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
notes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
canLoadMore: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isLoadingMore: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
viewMode: {
|
||||||
|
type: String,
|
||||||
|
default: "grid",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["selectNote", "loadMore"]);
|
||||||
|
|
||||||
|
const selectNote = (note) => {
|
||||||
|
emit("selectNote", note);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
return new Date(dateString).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDescription = (note) => {
|
||||||
|
const description = (note?.description || "").trim();
|
||||||
|
if (!description) {
|
||||||
|
return "No description";
|
||||||
|
}
|
||||||
|
return description;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.note-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-notes-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 48vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed #cfd6e4;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(180deg, #f8f9fc 0%, #eef3fb 100%);
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-notes-icon {
|
||||||
|
font-size: 5.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: #60789a;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-notes-title {
|
||||||
|
margin: 0;
|
||||||
|
color: #23364f;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-notes-message {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
max-width: 460px;
|
||||||
|
color: #4f637d;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-icon {
|
||||||
|
color: #408aca;
|
||||||
|
font-size: 0.9em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-icon {
|
||||||
|
color: #f08c00;
|
||||||
|
font-size: 0.95em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card.is-pinned {
|
||||||
|
background: #dbf5ff;
|
||||||
|
border-color: #a8d1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card.is-featured {
|
||||||
|
border-color: #ffd8a8;
|
||||||
|
background: #fff9db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-preview {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-footer {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List view overrides */
|
||||||
|
.note-list--list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-list--list .note-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-list--list .note-card:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-list--list .note-title {
|
||||||
|
flex: 0 0 220px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-list--list .note-preview {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-list--list .note-card > small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-list--list .list-footer {
|
||||||
|
grid-column: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.empty-notes-state {
|
||||||
|
min-height: 40vh;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-notes-icon {
|
||||||
|
font-size: 4.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-notes-title {
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
175
frontend/src/components/NoteViewer.vue
Normal file
175
frontend/src/components/NoteViewer.vue
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<template>
|
||||||
|
<article class="note-viewer">
|
||||||
|
<header class="note-meta mb-4">
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3" v-if="note.tags?.length">
|
||||||
|
<span v-for="tag in note.tags" :key="tag" class="tag-chip"><i class="mdi mdi-tag me-1" aria-hidden="true"></i>{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3" v-if="note.is_pinned || note.is_favorite || note.is_featured || typeof note.is_public === 'boolean'">
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3" v-if="note.is_pinned || note.is_favorite || note.is_featured || typeof note.is_public === 'boolean' || note.is_password_protected">
|
||||||
|
<span v-if="note.is_pinned" class="state-chip pinned-chip">Pinned</span>
|
||||||
|
<span v-if="note.is_favorite || note.is_featured" class="state-chip featured-chip">Featured</span>
|
||||||
|
<span :class="['state-chip', note.is_public ? 'public-chip' : 'private-chip']">
|
||||||
|
<i :class="note.is_public ? 'mdi mdi-eye me-1' : 'mdi mdi-eye-off me-1'" aria-hidden="true"></i>
|
||||||
|
{{ note.is_public ? "Public" : "Private" }}
|
||||||
|
</span>
|
||||||
|
<span v-if="note.is_password_protected" class="state-chip protected-chip">
|
||||||
|
<i class="mdi mdi-lock-outline me-1" aria-hidden="true"></i>
|
||||||
|
Password Protected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-grid text-muted small">
|
||||||
|
<span>Updated {{ formatDateTime(note.updated_at) }}</span>
|
||||||
|
<span v-if="categoryLabel">Category: {{ categoryLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="markdown-body" v-html="renderedMarkdown"></div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
note: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
categoryOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderedMarkdown = computed(() => {
|
||||||
|
const html = marked.parse(props.note.content || "");
|
||||||
|
return DOMPurify.sanitize(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryLabel = computed(() => {
|
||||||
|
const categoryId = props.note.category_id;
|
||||||
|
if (!categoryId) {
|
||||||
|
return "Uncategorized";
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedLabel = props.categoryOptions.find((category) => category.id === categoryId)?.label?.trim();
|
||||||
|
if (matchedLabel) {
|
||||||
|
return matchedLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directLabel = props.note.category_name?.trim();
|
||||||
|
if (directLabel) {
|
||||||
|
return directLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we cannot resolve the category name (e.g., public view without category options),
|
||||||
|
// avoid showing an incorrect "Uncategorized" state.
|
||||||
|
if (!props.categoryOptions.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Uncategorized";
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.note-viewer {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-meta {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #364fc7;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-chip {
|
||||||
|
color: #005f8f;
|
||||||
|
background: #dbf5ff;
|
||||||
|
border: 1px solid #a8d1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-chip {
|
||||||
|
color: #8d7619;
|
||||||
|
|
||||||
|
border: 1px solid #ffd8a8;
|
||||||
|
background: #fff9db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-chip {
|
||||||
|
color: #0c5460;
|
||||||
|
border: 1px solid #a5d8ff;
|
||||||
|
background: #e7f5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.private-chip {
|
||||||
|
color: #5f3dc4;
|
||||||
|
border: 1px solid #d0bfff;
|
||||||
|
background: #f3f0ff;
|
||||||
|
}
|
||||||
|
.protected-chip {
|
||||||
|
color: #7f5539;
|
||||||
|
border: 1px solid #e0c3a6;
|
||||||
|
background: #fff4e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(h1),
|
||||||
|
.markdown-body :deep(h2),
|
||||||
|
.markdown-body :deep(h3) {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(p),
|
||||||
|
.markdown-body :deep(li) {
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(pre) {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #111827;
|
||||||
|
color: #f9fafb;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(code) {
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(blockquote) {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-left: 4px solid #748ffc;
|
||||||
|
background: #f8f9ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
269
frontend/src/components/SpaceSettingsModal.vue
Normal file
269
frontend/src/components/SpaceSettingsModal.vue
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Space Settings</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Space name</label>
|
||||||
|
<input v-model="form.name" type="text" class="form-control" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check form-switch mb-4">
|
||||||
|
<input id="spacePublicToggle" v-model="form.is_public" class="form-check-input" type="checkbox" />
|
||||||
|
<label class="form-check-label" for="spacePublicToggle">Public space</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mb-4">
|
||||||
|
<button class="btn btn-primary" :disabled="saving" @click="saveSettings">
|
||||||
|
{{ saving ? "Saving..." : "Save Settings" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="canViewMembers">
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2 mt-3">
|
||||||
|
<h6 class="mb-0">Members</h6>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingMembers" @click="loadMembers">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="row g-2 align-items-end mb-3" @submit.prevent="addMember">
|
||||||
|
<div class="col-md-10">
|
||||||
|
<label class="form-label form-label-sm mb-1">Username</label>
|
||||||
|
<select v-model="memberForm.user_id" class="form-select form-select-sm" required :disabled="!canManageMembers">
|
||||||
|
<option disabled value="">Select user</option>
|
||||||
|
<option v-for="user in userOptions" :key="user.id" :value="user.id">{{ user.username }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm w-100" :disabled="addingMember || !canManageMembers">
|
||||||
|
{{ addingMember ? "..." : "Add" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-if="loadingMembers" class="text-muted small">Loading members...</div>
|
||||||
|
<div v-else-if="members.length === 0" class="text-muted small">No members found.</div>
|
||||||
|
|
||||||
|
<div v-else class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Joined</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="member in members" :key="member.user_id">
|
||||||
|
<td class="small text-muted">{{ member.username || member.user_id }}</td>
|
||||||
|
<td class="small text-muted">{{ formatDate(member.joined_at) }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-danger" :disabled="!canManageMembers || removingMemberId === member.user_id" @click="removeMember(member)">
|
||||||
|
{{ removingMemberId === member.user_id ? "Removing..." : "Remove" }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="canDeleteSpace">
|
||||||
|
<hr />
|
||||||
|
<div class="border border-danger rounded p-3 mt-3">
|
||||||
|
<h6 class="text-danger mb-1">Danger Zone</h6>
|
||||||
|
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p>
|
||||||
|
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace">
|
||||||
|
{{ deleting ? "Deleting..." : "Delete Space" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
|
||||||
|
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show"></div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import apiClient from "../services/apiClient";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
space: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "saved", "deleted"]);
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const deleting = ref(false);
|
||||||
|
const canDeleteSpace = computed(() => authStore.hasSpacePermission(props.space, "settings.delete"));
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: props.space.name || "",
|
||||||
|
description: props.space.description || "",
|
||||||
|
icon: props.space.icon || "",
|
||||||
|
is_public: !!props.space.is_public,
|
||||||
|
});
|
||||||
|
const members = ref([]);
|
||||||
|
const userOptions = ref([]);
|
||||||
|
const loadingMembers = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const addingMember = ref(false);
|
||||||
|
const removingMemberId = ref("");
|
||||||
|
const error = ref("");
|
||||||
|
const success = ref("");
|
||||||
|
const memberForm = ref({ user_id: "" });
|
||||||
|
const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view"));
|
||||||
|
const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage"));
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.space,
|
||||||
|
(space) => {
|
||||||
|
form.value = {
|
||||||
|
name: space.name || "",
|
||||||
|
description: space.description || "",
|
||||||
|
icon: space.icon || "",
|
||||||
|
is_public: !!space.is_public,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
|
||||||
|
|
||||||
|
const clearMessages = () => {
|
||||||
|
error.value = "";
|
||||||
|
success.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMembers = async () => {
|
||||||
|
if (!canViewMembers.value) {
|
||||||
|
members.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingMembers.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/api/v1/spaces/${props.space.id}/members`);
|
||||||
|
members.value = response.data.members || [];
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to load members.";
|
||||||
|
} finally {
|
||||||
|
loadingMembers.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUserOptions = async () => {
|
||||||
|
if (!canManageMembers.value) {
|
||||||
|
userOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/api/v1/spaces/${props.space.id}/available-users`);
|
||||||
|
userOptions.value = response.data.users || [];
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to load users.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSettings = async () => {
|
||||||
|
saving.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/api/v1/spaces/${props.space.id}`, {
|
||||||
|
name: form.value.name,
|
||||||
|
description: form.value.description,
|
||||||
|
icon: form.value.icon,
|
||||||
|
is_public: form.value.is_public,
|
||||||
|
});
|
||||||
|
success.value = "Space settings saved.";
|
||||||
|
emit("saved", response.data);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to save settings.";
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMember = async () => {
|
||||||
|
if (!canManageMembers.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!memberForm.value.user_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addingMember.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/api/v1/spaces/${props.space.id}/members`, {
|
||||||
|
user_id: memberForm.value.user_id,
|
||||||
|
});
|
||||||
|
success.value = "Member added.";
|
||||||
|
memberForm.value = { user_id: "" };
|
||||||
|
await Promise.all([loadMembers(), loadUserOptions()]);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to add member.";
|
||||||
|
} finally {
|
||||||
|
addingMember.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMember = async (member) => {
|
||||||
|
if (!canManageMembers.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberName = member?.username || member?.user_id;
|
||||||
|
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removingMemberId.value = member.user_id;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/v1/spaces/${props.space.id}/members/${member.user_id}`);
|
||||||
|
success.value = "Member removed.";
|
||||||
|
await Promise.all([loadMembers(), loadUserOptions()]);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to remove member.";
|
||||||
|
} finally {
|
||||||
|
removingMemberId.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (canViewMembers.value) {
|
||||||
|
Promise.all([loadMembers(), loadUserOptions()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSpace = async () => {
|
||||||
|
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleting.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/v1/spaces/${props.space.id}`);
|
||||||
|
emit("deleted", props.space);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to delete space.";
|
||||||
|
} finally {
|
||||||
|
deleting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
13
frontend/src/main.js
Normal file
13
frontend/src/main.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import router from "./router";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
|
import "@mdi/font/css/materialdesignicons.min.css";
|
||||||
|
import "./assets/styles/main.css";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
app.use(createPinia());
|
||||||
|
app.use(router);
|
||||||
|
app.mount("#app");
|
||||||
631
frontend/src/pages/Admin.vue
Normal file
631
frontend/src/pages/Admin.vue
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
<template>
|
||||||
|
<div class="admin-page">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1">Admin Panel</h2>
|
||||||
|
<p class="text-muted mb-0">Manage users, groups, spaces, and identity providers.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary" @click="router.push('/')">Back to Notes</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger">{{ error }}</div>
|
||||||
|
<div v-if="successMessage" class="alert alert-success">{{ successMessage }}</div>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link" :class="{ active: activeTab === 'groups' }" @click="activeTab = 'groups'">Groups</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link" :class="{ active: activeTab === 'spaces' }" @click="activeTab = 'spaces'">Spaces</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link" :class="{ active: activeTab === 'providers' }" @click="activeTab = 'providers'">Identity Providers</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link" :class="{ active: activeTab === 'featureFlags' }" @click="activeTab = 'featureFlags'">Feature Flags</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<section v-if="activeTab === 'users'" class="admin-section card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h5 class="mb-0">All Users</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingUsers" @click="loadUsers">Refresh</button>
|
||||||
|
</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 class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Groups</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Joined</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in users" :key="u.id">
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td class="text-muted small">{{ u.email }}</td>
|
||||||
|
<td style="min-width: 260px">
|
||||||
|
<select
|
||||||
|
class="form-select form-select-sm"
|
||||||
|
multiple
|
||||||
|
:value="u.group_ids || []"
|
||||||
|
@change="
|
||||||
|
updateUserGroups(
|
||||||
|
u.id,
|
||||||
|
Array.from($event.target.selectedOptions).map((option) => option.value),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option v-for="group in groups" :key="group.id" :value="group.id">
|
||||||
|
{{ group.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="small text-muted mt-1">Ctrl/Cmd+Click for multiple groups</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="u.is_active ? 'text-bg-success' : 'text-bg-secondary'">
|
||||||
|
{{ u.is_active ? "Active" : "Inactive" }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">{{ formatDate(u.created_at) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="activeTab === 'groups'" class="admin-section card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h5 class="mb-0">Permission Groups</h5>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingGroups" @click="loadGroups">Refresh</button>
|
||||||
|
<button class="btn btn-sm btn-primary" @click="openCreateGroupModal">Create Group</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingGroups" class="text-muted small">Loading groups...</div>
|
||||||
|
<div v-else-if="groups.length === 0" class="border rounded p-3 text-muted">No groups created yet.</div>
|
||||||
|
<div v-else class="list-group">
|
||||||
|
<div v-for="group in groups" :key="group.id" class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold d-flex align-items-center gap-2">
|
||||||
|
<span>{{ group.name }}</span>
|
||||||
|
<span v-if="group.is_system" class="badge text-bg-dark">System</span>
|
||||||
|
</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>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="activeTab === 'spaces'" class="admin-section card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h5 class="mb-0">All Spaces</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingSpaces" @click="loadSpaces">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingSpaces" class="text-muted small">Loading spaces...</div>
|
||||||
|
<div v-else-if="spaces.length === 0" class="border rounded p-3 text-muted">No spaces found.</div>
|
||||||
|
<div v-else class="list-group mb-3">
|
||||||
|
<div v-for="space in spaces" :key="space.id" class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">{{ space.name }}</div>
|
||||||
|
<div class="small text-muted">{{ space.description || "No description" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge" :class="space.is_public ? 'text-bg-success' : 'text-bg-secondary'">
|
||||||
|
{{ space.is_public ? "Public" : "Private" }}
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @click="openSpaceModal(space)">Edit Space</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="activeTab === 'providers'" class="admin-section card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h5 class="mb-0">Configured Providers</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingProviders" @click="loadProviders">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingProviders" class="text-muted small">Loading providers...</div>
|
||||||
|
<div v-else-if="providers.length === 0" class="border rounded p-3 text-muted">No providers configured yet.</div>
|
||||||
|
<div v-else class="list-group mb-3">
|
||||||
|
<div v-for="provider in providers" :key="provider.id" class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">{{ provider.name }}</div>
|
||||||
|
<div class="small text-muted">{{ provider.type.toUpperCase() }} · {{ provider.scopes.join(", ") }}</div>
|
||||||
|
<div class="small text-muted">Callback: {{ buildCallbackUrl(provider.id) }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge" :class="provider.is_active ? 'text-bg-success' : 'text-bg-secondary'">
|
||||||
|
{{ provider.is_active ? "Active" : "Disabled" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="mb-2">Add Provider</h6>
|
||||||
|
<form class="row g-3" @submit.prevent="createProvider">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Display Name</label>
|
||||||
|
<input v-model="providerForm.name" type="text" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Provider Type</label>
|
||||||
|
<select v-model="providerForm.type" class="form-select">
|
||||||
|
<option value="oidc">OIDC</option>
|
||||||
|
<option value="oauth2">OAuth2</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Client ID</label>
|
||||||
|
<input v-model="providerForm.client_id" type="text" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Client Secret</label>
|
||||||
|
<input v-model="providerForm.client_secret" type="password" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Authorization URL</label>
|
||||||
|
<input v-model="providerForm.authorization_url" type="url" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Token URL</label>
|
||||||
|
<input v-model="providerForm.token_url" type="url" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">UserInfo URL</label>
|
||||||
|
<input v-model="providerForm.userinfo_url" type="url" class="form-control" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">ID Token Field</label>
|
||||||
|
<input v-model="providerForm.id_token_claim" type="text" class="form-control" placeholder="id_token" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Scopes</label>
|
||||||
|
<input v-model="providerForm.scopes" type="text" class="form-control" placeholder="openid, profile, email" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 form-check ms-2">
|
||||||
|
<input id="provider-active" v-model="providerForm.is_active" type="checkbox" class="form-check-input" />
|
||||||
|
<label for="provider-active" class="form-check-label">Provider is active</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="submittingProvider">
|
||||||
|
{{ submittingProvider ? "Saving..." : "Add Provider" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="activeTab === 'featureFlags'" class="admin-section card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h5 class="mb-0">Application Feature Flags</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" :disabled="loadingFeatureFlags" @click="loadFeatureFlags">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingFeatureFlags" class="text-muted small">Loading feature flags...</div>
|
||||||
|
|
||||||
|
<div v-else class="d-grid gap-3">
|
||||||
|
<div class="feature-flag-item border rounded p-3 d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">Enable User Registration</div>
|
||||||
|
<div class="small text-muted">Controls whether new users can sign up from the register page.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch m-0">
|
||||||
|
<input id="flag-registration" v-model="featureFlagsForm.registration_enabled" class="form-check-input" type="checkbox" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-flag-item border rounded p-3 d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">Enable Provider Login</div>
|
||||||
|
<div class="small text-muted">Controls OAuth/OIDC sign-in buttons and provider login endpoints.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch m-0">
|
||||||
|
<input id="flag-provider-login" v-model="featureFlagsForm.provider_login_enabled" class="form-check-input" type="checkbox" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-flag-item border rounded p-3 d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">Enable Public Sharing</div>
|
||||||
|
<div class="small text-muted">Reserved for public content controls and future sharing gates.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch m-0">
|
||||||
|
<input id="flag-public-sharing" v-model="featureFlagsForm.public_sharing_enabled" class="form-check-input" type="checkbox" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button class="btn btn-primary" :disabled="savingFeatureFlags" @click="saveFeatureFlags">
|
||||||
|
{{ savingFeatureFlags ? "Saving..." : "Save Feature Flags" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminSpaceModal v-if="showSpaceModal && selectedSpace" :space="selectedSpace" :users="users" @close="showSpaceModal = false" @saved="onSpaceSaved" @deleted="onSpaceDeleted" />
|
||||||
|
|
||||||
|
<teleport to="body">
|
||||||
|
<div v-if="showGroupModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeGroupModal">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ groupModalMode === "create" ? "Create Group" : "Edit Group" }}</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="closeGroupModal"></button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="submitGroupModal">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Group name</label>
|
||||||
|
<input v-model="groupModalForm.name" class="form-control" type="text" required :disabled="isEditingSystemGroup" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input v-model="groupModalForm.description" class="form-control" type="text" :disabled="isEditingSystemGroup" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Permissions (one per line)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="groupModalForm.permissionsText"
|
||||||
|
class="form-control permissions-textarea"
|
||||||
|
rows="10"
|
||||||
|
placeholder="space.create space.project_docs.category.create space.project_docs.*"
|
||||||
|
:disabled="isEditingSystemGroup"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="closeGroupModal">Cancel</button>
|
||||||
|
<button v-if="!isEditingSystemGroup" type="submit" class="btn btn-primary" :disabled="submittingGroupModal">
|
||||||
|
{{ submittingGroupModal ? "Saving..." : groupModalMode === "create" ? "Create Group" : "Save Changes" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showGroupModal" class="modal-backdrop fade show"></div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import apiClient from "../services/apiClient";
|
||||||
|
import AdminSpaceModal from "../components/AdminSpaceModal.vue";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const activeTab = ref("users");
|
||||||
|
const error = ref("");
|
||||||
|
const successMessage = ref("");
|
||||||
|
|
||||||
|
const users = ref([]);
|
||||||
|
const loadingUsers = ref(false);
|
||||||
|
|
||||||
|
const groups = ref([]);
|
||||||
|
const loadingGroups = ref(false);
|
||||||
|
const showGroupModal = ref(false);
|
||||||
|
const groupModalMode = ref("create");
|
||||||
|
const editingGroupId = ref("");
|
||||||
|
const submittingGroupModal = ref(false);
|
||||||
|
const groupModalForm = ref({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
permissionsText: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const spaces = ref([]);
|
||||||
|
const loadingSpaces = ref(false);
|
||||||
|
const showSpaceModal = ref(false);
|
||||||
|
const selectedSpace = ref(null);
|
||||||
|
|
||||||
|
const providers = ref([]);
|
||||||
|
const loadingProviders = ref(false);
|
||||||
|
const submittingProvider = ref(false);
|
||||||
|
const providerForm = ref({
|
||||||
|
name: "",
|
||||||
|
type: "oidc",
|
||||||
|
client_id: "",
|
||||||
|
client_secret: "",
|
||||||
|
authorization_url: "",
|
||||||
|
token_url: "",
|
||||||
|
userinfo_url: "",
|
||||||
|
id_token_claim: "id_token",
|
||||||
|
scopes: "openid, profile, email",
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadingFeatureFlags = ref(false);
|
||||||
|
const savingFeatureFlags = ref(false);
|
||||||
|
const featureFlagsForm = ref({
|
||||||
|
registration_enabled: true,
|
||||||
|
provider_login_enabled: true,
|
||||||
|
public_sharing_enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearMessages = () => {
|
||||||
|
error.value = "";
|
||||||
|
successMessage.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (iso) => {
|
||||||
|
if (!iso) return "";
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
loadingUsers.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get("/api/v1/admin/users");
|
||||||
|
users.value = res.data.users || [];
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to load users.";
|
||||||
|
} finally {
|
||||||
|
loadingUsers.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUserGroups = async (userId, groupIds) => {
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/api/v1/admin/users/${userId}/groups`, { group_ids: groupIds });
|
||||||
|
const updatedUser = response.data;
|
||||||
|
const userIndex = users.value.findIndex((user) => user.id === userId);
|
||||||
|
if (userIndex !== -1) {
|
||||||
|
users.value[userIndex] = { ...users.value[userIndex], ...updatedUser };
|
||||||
|
}
|
||||||
|
successMessage.value = "User groups updated.";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to update user groups.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetGroupModalForm = () => {
|
||||||
|
groupModalForm.value = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
permissionsText: "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEditingSystemGroup = computed(() => {
|
||||||
|
if (groupModalMode.value !== "edit") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const group = groups.value.find((item) => item.id === editingGroupId.value);
|
||||||
|
return !!group?.is_system;
|
||||||
|
});
|
||||||
|
|
||||||
|
const splitPermissionsByNewline = (raw) =>
|
||||||
|
(raw || "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((permission) => permission.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const openCreateGroupModal = () => {
|
||||||
|
groupModalMode.value = "create";
|
||||||
|
editingGroupId.value = "";
|
||||||
|
resetGroupModalForm();
|
||||||
|
showGroupModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditGroupModal = (group) => {
|
||||||
|
groupModalMode.value = "edit";
|
||||||
|
editingGroupId.value = group.id;
|
||||||
|
groupModalForm.value = {
|
||||||
|
name: group.name || "",
|
||||||
|
description: group.description || "",
|
||||||
|
permissionsText: (group.permissions || []).join("\n"),
|
||||||
|
};
|
||||||
|
showGroupModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeGroupModal = () => {
|
||||||
|
showGroupModal.value = false;
|
||||||
|
submittingGroupModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadGroups = async () => {
|
||||||
|
loadingGroups.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get("/api/v1/admin/groups");
|
||||||
|
groups.value = res.data.groups || [];
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to load groups.";
|
||||||
|
} finally {
|
||||||
|
loadingGroups.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitGroupModal = async () => {
|
||||||
|
submittingGroupModal.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: groupModalForm.value.name,
|
||||||
|
description: groupModalForm.value.description,
|
||||||
|
permissions: splitPermissionsByNewline(groupModalForm.value.permissionsText),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (groupModalMode.value === "create") {
|
||||||
|
await apiClient.post("/api/v1/admin/groups", payload);
|
||||||
|
successMessage.value = "Group created.";
|
||||||
|
} else {
|
||||||
|
await apiClient.put(`/api/v1/admin/groups/${editingGroupId.value}`, payload);
|
||||||
|
successMessage.value = "Group updated.";
|
||||||
|
}
|
||||||
|
|
||||||
|
closeGroupModal();
|
||||||
|
resetGroupModalForm();
|
||||||
|
await Promise.all([loadGroups(), loadUsers()]);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || `Failed to ${groupModalMode.value === "create" ? "create" : "update"} group.`;
|
||||||
|
} finally {
|
||||||
|
submittingGroupModal.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSpaces = async () => {
|
||||||
|
loadingSpaces.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get("/api/v1/admin/spaces");
|
||||||
|
spaces.value = res.data.spaces || [];
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to load spaces.";
|
||||||
|
} finally {
|
||||||
|
loadingSpaces.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSpaceModal = (space) => {
|
||||||
|
selectedSpace.value = { ...space };
|
||||||
|
showSpaceModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSpaceSaved = (updatedSpace) => {
|
||||||
|
const index = spaces.value.findIndex((space) => space.id === updatedSpace.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
spaces.value[index] = { ...spaces.value[index], ...updatedSpace };
|
||||||
|
}
|
||||||
|
selectedSpace.value = { ...updatedSpace };
|
||||||
|
successMessage.value = "Space updated.";
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSpaceDeleted = (deletedSpace) => {
|
||||||
|
spaces.value = spaces.value.filter((space) => space.id !== deletedSpace.id);
|
||||||
|
showSpaceModal.value = false;
|
||||||
|
selectedSpace.value = null;
|
||||||
|
successMessage.value = `Space "${deletedSpace.name}" deleted.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCallbackUrl = (providerId) => `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/callback`;
|
||||||
|
|
||||||
|
const resetProviderForm = () => {
|
||||||
|
providerForm.value = {
|
||||||
|
name: "",
|
||||||
|
type: "oidc",
|
||||||
|
client_id: "",
|
||||||
|
client_secret: "",
|
||||||
|
authorization_url: "",
|
||||||
|
token_url: "",
|
||||||
|
userinfo_url: "",
|
||||||
|
id_token_claim: "id_token",
|
||||||
|
scopes: "openid, profile, email",
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadProviders = async () => {
|
||||||
|
loadingProviders.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get("/api/v1/auth/providers");
|
||||||
|
providers.value = res.data.providers || [];
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to load providers.";
|
||||||
|
} finally {
|
||||||
|
loadingProviders.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createProvider = async () => {
|
||||||
|
submittingProvider.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
await apiClient.post("/api/v1/admin/auth/providers", {
|
||||||
|
...providerForm.value,
|
||||||
|
scopes: providerForm.value.scopes
|
||||||
|
.split(",")
|
||||||
|
.map((scope) => scope.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
});
|
||||||
|
successMessage.value = "Provider added.";
|
||||||
|
resetProviderForm();
|
||||||
|
await loadProviders();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to create provider.";
|
||||||
|
} finally {
|
||||||
|
submittingProvider.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFeatureFlags = async () => {
|
||||||
|
loadingFeatureFlags.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get("/api/v1/admin/feature-flags");
|
||||||
|
featureFlagsForm.value = {
|
||||||
|
registration_enabled: !!res.data.registration_enabled,
|
||||||
|
provider_login_enabled: !!res.data.provider_login_enabled,
|
||||||
|
public_sharing_enabled: !!res.data.public_sharing_enabled,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to load feature flags.";
|
||||||
|
} finally {
|
||||||
|
loadingFeatureFlags.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveFeatureFlags = async () => {
|
||||||
|
savingFeatureFlags.value = true;
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
const res = await apiClient.put("/api/v1/admin/feature-flags", featureFlagsForm.value);
|
||||||
|
featureFlagsForm.value = {
|
||||||
|
registration_enabled: !!res.data.registration_enabled,
|
||||||
|
provider_login_enabled: !!res.data.provider_login_enabled,
|
||||||
|
public_sharing_enabled: !!res.data.public_sharing_enabled,
|
||||||
|
};
|
||||||
|
successMessage.value = "Feature flags updated.";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data || "Failed to update feature flags.";
|
||||||
|
} finally {
|
||||||
|
savingFeatureFlags.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadUsers(), loadGroups(), loadSpaces(), loadProviders(), loadFeatureFlags()]);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-page {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permissions-textarea {
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
frontend/src/pages/Home.vue
Normal file
7
frontend/src/pages/Home.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home-page">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup></script>
|
||||||
298
frontend/src/pages/Login.vue
Normal file
298
frontend/src/pages/Login.vue
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="brand-block">
|
||||||
|
<div class="brand-mark">
|
||||||
|
<i class="mdi mdi-note-text-outline" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="brand-title">Notely</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="auth-title">Login</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email</label>
|
||||||
|
<input id="email" v-model="form.email" type="email" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input id="password" v-model="form.password" type="password" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger">{{ error }}</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100 auth-submit">Login</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-if="providerLoginEnabled && providers.length" class="mt-4">
|
||||||
|
<div class="oauth-divider"><span>or continue with</span></div>
|
||||||
|
<div class="d-grid gap-2 mt-3">
|
||||||
|
<button v-for="provider in providers" :key="provider.id" type="button" class="btn btn-outline-dark auth-provider-btn" @click="startProviderLogin(provider.id)">
|
||||||
|
Sign in with {{ provider.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="registrationEnabled" class="text-center mt-4 mb-0 auth-switch-link">
|
||||||
|
Don't have an account?
|
||||||
|
<router-link to="/register">Register here</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import { useSettingsStore } from "../stores/settingsStore";
|
||||||
|
import apiClient from "../services/apiClient";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const error = ref("");
|
||||||
|
const providers = ref([]);
|
||||||
|
const registrationEnabled = ref(true);
|
||||||
|
const providerLoginEnabled = ref(true);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await authStore.login(form.value.email, form.value.password);
|
||||||
|
router.push("/");
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadProviders = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/api/v1/auth/providers");
|
||||||
|
providers.value = response.data.providers || [];
|
||||||
|
} catch {
|
||||||
|
providers.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startProviderLogin = (providerId) => {
|
||||||
|
window.location.href = `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/start`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeBase64Url = (value) => {
|
||||||
|
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padding = normalized.length % 4;
|
||||||
|
const padded = padding === 0 ? normalized : `${normalized}${"=".repeat(4 - padding)}`;
|
||||||
|
return atob(padded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeBase64UrlUTF8 = (value) => {
|
||||||
|
const binary = decodeBase64Url(value);
|
||||||
|
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readUserFromQuery = (params) => {
|
||||||
|
const plainUserJSON = params.get("user_json");
|
||||||
|
if (plainUserJSON) {
|
||||||
|
return JSON.parse(plainUserJSON);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedUser = params.get("user");
|
||||||
|
if (encodedUser) {
|
||||||
|
return JSON.parse(decodeBase64UrlUTF8(encodedUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeOAuthRedirect = async () => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const status = params.get("status");
|
||||||
|
const accessToken = params.get("access_token") || params.get("accessToken") || params.get("token");
|
||||||
|
|
||||||
|
if (status === "oauth_error") {
|
||||||
|
error.value = params.get("message") || "Provider sign-in failed.";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept callback payloads even when `status` is missing.
|
||||||
|
if (status !== "oauth_success" && !accessToken) {
|
||||||
|
if (status === "oauth_error") {
|
||||||
|
error.value = params.get("message") || "Provider sign-in failed.";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
error.value = "Provider sign-in returned an incomplete session.";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = readUserFromQuery(params);
|
||||||
|
if (!user) {
|
||||||
|
error.value = "Provider sign-in returned an incomplete session.";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
authStore.setSession({ access_token: accessToken, user });
|
||||||
|
await router.replace("/");
|
||||||
|
} catch {
|
||||||
|
error.value = "Unable to restore the provider session.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
window.location.replace("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const flags = await settingsStore.loadFeatureFlags();
|
||||||
|
registrationEnabled.value = !!flags.registration_enabled;
|
||||||
|
providerLoginEnabled.value = !!flags.provider_login_enabled;
|
||||||
|
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
await router.replace("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handledOAuthCallback = await completeOAuthRedirect();
|
||||||
|
if (!handledOAuthCallback && providerLoginEnabled.value) {
|
||||||
|
await loadProviders();
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryMessage = typeof router.currentRoute.value.query.message === "string" ? router.currentRoute.value.query.message : "";
|
||||||
|
if (!error.value && queryMessage) {
|
||||||
|
error.value = queryMessage;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: #fff;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(53, 84, 209, 0.12);
|
||||||
|
color: #2f4ac1;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: #2f3237;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 2.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: #2f3237;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
min-height: 48px;
|
||||||
|
border-color: #d6dbe4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit {
|
||||||
|
min-height: 48px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-provider-btn {
|
||||||
|
min-height: 48px;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-divider::before,
|
||||||
|
.oauth-divider::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-divider span {
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-switch-link {
|
||||||
|
color: #4b5565;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.login-page {
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-size: 1.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
395
frontend/src/pages/PublicSpace.vue
Normal file
395
frontend/src/pages/PublicSpace.vue
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
<template>
|
||||||
|
<div class="public-layout">
|
||||||
|
<!-- Minimal navbar -->
|
||||||
|
<nav ref="navbarRef" class="navbar navbar-dark bg-dark sticky-top">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button v-if="!loading && !errorType" class="btn btn-outline-light d-md-none" type="button" aria-label="Toggle notes list" @click="showSidebar = !showSidebar">
|
||||||
|
<i class="mdi mdi-menu" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<router-link to="/" class="navbar-brand mb-0 h1 d-flex align-items-center gap-2">
|
||||||
|
<i class="mdi mdi-notebook-outline" aria-hidden="true"></i>
|
||||||
|
<span>Notely</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<router-link v-if="!isAuthenticated" to="/login" class="btn btn-primary btn-sm">Login</router-link>
|
||||||
|
<router-link v-else to="/" class="btn btn-outline-light btn-sm">My Spaces</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="d-flex justify-content-center align-items-center" style="min-height: 80vh">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="errorType" class="d-flex flex-column justify-content-center align-items-center text-center" style="min-height: 80vh">
|
||||||
|
<h2 class="mb-3">{{ errorTitle }}</h2>
|
||||||
|
<p class="text-muted mb-4">{{ errorDescription }}</p>
|
||||||
|
<router-link to="/login" class="btn btn-primary">Sign in to access private spaces</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-else class="d-flex public-body" :style="publicBodyStyle">
|
||||||
|
<div v-if="showSidebar" class="public-sidebar-backdrop" :style="offcanvasOffsetStyle" @click="showSidebar = false"></div>
|
||||||
|
|
||||||
|
<!-- Sidebar: note list -->
|
||||||
|
<aside class="public-sidebar bg-light border-end p-3" :class="{ open: showSidebar }" :style="offcanvasOffsetStyle">
|
||||||
|
<div class="mb-3">
|
||||||
|
<h5 class="mb-1 d-flex align-items-center gap-2">
|
||||||
|
<i class="mdi mdi-folder-outline" aria-hidden="true"></i>
|
||||||
|
<span>{{ space.name }}</span>
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted small mb-0">{{ space.description }}</p>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div v-if="sortedPublicNotes.length === 0" class="text-muted small">No notes in this space.</div>
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
<li v-for="note in sortedPublicNotes" :key="note.id" class="mb-1">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm w-100 text-start note-item"
|
||||||
|
:class="{ active: selectedNote?.id === note.id, 'is-featured': note.is_favorite || note.is_featured }"
|
||||||
|
@click="selectAndRouteNote(note)"
|
||||||
|
>
|
||||||
|
<span class="d-block fw-semibold text-truncate">{{ note.title }}</span>
|
||||||
|
<span class="d-block text-muted small">{{ formatDate(note.updated_at) }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Load more -->
|
||||||
|
<button v-if="hasMore" class="btn btn-sm btn-outline-secondary w-100 mt-2" @click="loadMore">Load more</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main: note content -->
|
||||||
|
<main class="flex-grow-1 p-4 overflow-auto">
|
||||||
|
<div v-if="selectedNote">
|
||||||
|
<h2 class="mb-3">{{ selectedNote.title }}</h2>
|
||||||
|
<NoteViewer :note="selectedNote" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center text-muted mt-5">
|
||||||
|
<p>Select a note from the list to read it.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<teleport to="body">
|
||||||
|
<div v-if="showUnlockModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeUnlockModal">
|
||||||
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title d-flex align-items-center gap-2 mb-0">
|
||||||
|
<i class="mdi mdi-lock-outline" aria-hidden="true"></i>
|
||||||
|
<span>Unlock Note</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="closeUnlockModal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Enter the password to view <strong>{{ unlockTargetNote?.title }}</strong
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<label class="form-label" for="unlockPublicNotePassword">Password</label>
|
||||||
|
<input id="unlockPublicNotePassword" v-model="unlockPassword" type="password" class="form-control" maxlength="128" @keyup.enter="unlockProtectedNote" />
|
||||||
|
<div v-if="unlockError" class="text-danger small mt-2">{{ unlockError }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="closeUnlockModal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" :disabled="unlockingNote" @click="unlockProtectedNote">
|
||||||
|
{{ unlockingNote ? "Unlocking..." : "Unlock" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showUnlockModal" class="modal-backdrop fade show"></div>
|
||||||
|
</teleport>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, nextTick, onBeforeUnmount, onMounted, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import apiClient from "../services/apiClient";
|
||||||
|
import NoteViewer from "../components/NoteViewer.vue";
|
||||||
|
import { sortNotesByPriority } from "../utils/noteSort";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const isAuthenticated = ref(authStore.isAuthenticated);
|
||||||
|
const loading = ref(true);
|
||||||
|
const errorType = ref(null);
|
||||||
|
const space = ref(null);
|
||||||
|
const notes = ref([]);
|
||||||
|
const selectedNote = ref(null);
|
||||||
|
const showSidebar = ref(false);
|
||||||
|
const navbarRef = ref(null);
|
||||||
|
const navbarHeight = ref(56);
|
||||||
|
const skip = ref(0);
|
||||||
|
const limit = 50;
|
||||||
|
const hasMore = ref(false);
|
||||||
|
const showUnlockModal = ref(false);
|
||||||
|
const unlockTargetNote = ref(null);
|
||||||
|
const unlockPassword = ref("");
|
||||||
|
const unlockError = ref("");
|
||||||
|
const unlockingNote = ref(false);
|
||||||
|
|
||||||
|
const sortedPublicNotes = computed(() => sortNotesByPriority(notes.value));
|
||||||
|
|
||||||
|
const offcanvasOffsetStyle = computed(() => ({
|
||||||
|
top: `${navbarHeight.value}px`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const publicBodyStyle = computed(() => ({
|
||||||
|
height: `calc(100vh - ${navbarHeight.value}px)`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const errorTitle = computed(() => {
|
||||||
|
if (errorType.value === "note-not-found") {
|
||||||
|
return "Note not found";
|
||||||
|
}
|
||||||
|
if (errorType.value === "space-not-found") {
|
||||||
|
return "Space not found";
|
||||||
|
}
|
||||||
|
return "This space is not publicly accessible";
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorDescription = computed(() => {
|
||||||
|
if (errorType.value === "note-not-found") {
|
||||||
|
return "The note you are looking for does not exist or is not public.";
|
||||||
|
}
|
||||||
|
if (errorType.value === "space-not-found") {
|
||||||
|
return "The space you are looking for does not exist.";
|
||||||
|
}
|
||||||
|
return "The space you are looking for is private.";
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = (dateString) => new Date(dateString).toLocaleDateString();
|
||||||
|
|
||||||
|
const upsertNoteInList = (note) => {
|
||||||
|
const index = notes.value.findIndex((n) => n.id === note.id);
|
||||||
|
if (index === -1) {
|
||||||
|
notes.value.unshift(note);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notes.value[index] = note;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadNotes = async () => {
|
||||||
|
const response = await apiClient.get(`/api/v1/public/spaces/${route.params.spaceId}/notes`, {
|
||||||
|
params: { skip: skip.value, limit },
|
||||||
|
});
|
||||||
|
const fetched = response.data.notes || [];
|
||||||
|
fetched.forEach(upsertNoteInList);
|
||||||
|
hasMore.value = fetched.length === limit;
|
||||||
|
skip.value += fetched.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
await loadNotes();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAndRouteNote = (note) => {
|
||||||
|
if (note?.is_password_protected) {
|
||||||
|
unlockTargetNote.value = note;
|
||||||
|
unlockPassword.value = "";
|
||||||
|
unlockError.value = "";
|
||||||
|
showUnlockModal.value = true;
|
||||||
|
selectedNote.value = null;
|
||||||
|
showSidebar.value = false;
|
||||||
|
router.replace(`/s/${route.params.spaceId}/n/${note.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedNote.value = note;
|
||||||
|
showSidebar.value = false;
|
||||||
|
router.replace(`/s/${route.params.spaceId}/n/${note.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPublicNote = async (noteId) => {
|
||||||
|
const response = await apiClient.get(`/api/v1/public/spaces/${route.params.spaceId}/notes/${noteId}`);
|
||||||
|
const note = response.data;
|
||||||
|
upsertNoteInList(note);
|
||||||
|
if (note?.is_password_protected) {
|
||||||
|
unlockTargetNote.value = note;
|
||||||
|
unlockPassword.value = "";
|
||||||
|
unlockError.value = "";
|
||||||
|
showUnlockModal.value = true;
|
||||||
|
selectedNote.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedNote.value = note;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeUnlockModal = () => {
|
||||||
|
showUnlockModal.value = false;
|
||||||
|
unlockTargetNote.value = null;
|
||||||
|
unlockPassword.value = "";
|
||||||
|
unlockError.value = "";
|
||||||
|
unlockingNote.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlockProtectedNote = async () => {
|
||||||
|
if (!unlockTargetNote.value?.id) {
|
||||||
|
closeUnlockModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!unlockPassword.value.trim()) {
|
||||||
|
unlockError.value = "Password is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockingNote.value = true;
|
||||||
|
unlockError.value = "";
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/api/v1/public/spaces/${route.params.spaceId}/notes/${unlockTargetNote.value.id}/unlock`, {
|
||||||
|
password: unlockPassword.value,
|
||||||
|
});
|
||||||
|
selectedNote.value = response.data;
|
||||||
|
upsertNoteInList(response.data);
|
||||||
|
closeUnlockModal();
|
||||||
|
} catch (error) {
|
||||||
|
unlockError.value = error?.response?.data || "Invalid password.";
|
||||||
|
} finally {
|
||||||
|
unlockingNote.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNavbarHeight = () => {
|
||||||
|
navbarHeight.value = navbarRef.value?.offsetHeight || 56;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscapeKey = (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
showSidebar.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
document.addEventListener("keydown", handleEscapeKey);
|
||||||
|
window.addEventListener("resize", updateNavbarHeight);
|
||||||
|
await nextTick();
|
||||||
|
updateNavbarHeight();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [spaceRes] = await Promise.all([apiClient.get(`/api/v1/public/spaces/${route.params.spaceId}`)]);
|
||||||
|
space.value = spaceRes.data;
|
||||||
|
if (route.params.noteId) {
|
||||||
|
try {
|
||||||
|
await loadPublicNote(route.params.noteId);
|
||||||
|
await loadNotes();
|
||||||
|
} catch {
|
||||||
|
errorType.value = "note-not-found";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await loadNotes();
|
||||||
|
if (sortedPublicNotes.value.length > 0) {
|
||||||
|
selectAndRouteNote(sortedPublicNotes.value[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorType.value = err.response?.status === 404 ? "space-not-found" : "space-not-public";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
await nextTick();
|
||||||
|
updateNavbarHeight();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener("keydown", handleEscapeKey);
|
||||||
|
window.removeEventListener("resize", updateNavbarHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.params.noteId,
|
||||||
|
async (noteId) => {
|
||||||
|
if (!noteId || loading.value) return;
|
||||||
|
try {
|
||||||
|
await loadPublicNote(noteId);
|
||||||
|
errorType.value = null;
|
||||||
|
} catch {
|
||||||
|
errorType.value = "note-not-found";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.public-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item {
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item.active {
|
||||||
|
background: #dbe4ff;
|
||||||
|
border-color: #748ffc;
|
||||||
|
color: #364fc7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item.is-featured {
|
||||||
|
background: #fff4e6;
|
||||||
|
border-color: #ffd8a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item.is-featured:hover {
|
||||||
|
background: #ffe8cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.public-sidebar-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1090;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1095;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
205
frontend/src/pages/Register.vue
Normal file
205
frontend/src/pages/Register.vue
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<template>
|
||||||
|
<div class="register-page">
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="register-card">
|
||||||
|
<div class="brand-block">
|
||||||
|
<div class="brand-mark">
|
||||||
|
<i class="mdi mdi-note-text-outline" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="brand-title">Notely</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="auth-title">Register</h2>
|
||||||
|
|
||||||
|
<div v-if="!registrationEnabled" class="alert alert-warning">
|
||||||
|
Registration is currently disabled by an administrator.
|
||||||
|
<router-link to="/login" class="alert-link ms-1">Go to login</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleRegister" :class="{ 'opacity-50': !registrationEnabled }">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12 col-md-6 mb-3 mb-md-0">
|
||||||
|
<label for="firstName" class="form-label">First Name</label>
|
||||||
|
<input id="firstName" v-model="form.firstName" type="text" class="form-control" :disabled="!registrationEnabled" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label for="lastName" class="form-label">Last Name</label>
|
||||||
|
<input id="lastName" v-model="form.lastName" type="text" class="form-control" :disabled="!registrationEnabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Username</label>
|
||||||
|
<input id="username" v-model="form.username" type="text" class="form-control" required :disabled="!registrationEnabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email</label>
|
||||||
|
<input id="email" v-model="form.email" type="email" class="form-control" required :disabled="!registrationEnabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input id="password" v-model="form.password" type="password" class="form-control" required :disabled="!registrationEnabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||||
|
<input id="confirmPassword" v-model="form.confirmPassword" type="password" class="form-control" required :disabled="!registrationEnabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger">{{ error }}</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100 auth-submit" :disabled="!registrationEnabled">Register</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-center mt-4 mb-0 auth-switch-link">
|
||||||
|
Already have an account?
|
||||||
|
<router-link to="/login">Login here</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import { useSettingsStore } from "../stores/settingsStore";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
email: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
});
|
||||||
|
const error = ref("");
|
||||||
|
const registrationEnabled = ref(true);
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
if (!registrationEnabled.value) {
|
||||||
|
error.value = "Registration is currently disabled.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.value.password !== form.value.confirmPassword) {
|
||||||
|
error.value = "Passwords do not match";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.register(form.value.email, form.value.username, form.value.password, form.value.firstName, form.value.lastName);
|
||||||
|
router.push("/");
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const flags = await settingsStore.loadFeatureFlags();
|
||||||
|
registrationEnabled.value = !!flags.registration_enabled;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.register-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-card {
|
||||||
|
background: #fff;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(53, 84, 209, 0.12);
|
||||||
|
color: #2f4ac1;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: #2f3237;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 2.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: #2f3237;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
min-height: 48px;
|
||||||
|
border-color: #d6dbe4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit {
|
||||||
|
min-height: 48px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-switch-link {
|
||||||
|
color: #4b5565;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.register-page {
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-card {
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-size: 1.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
123
frontend/src/router/index.js
Normal file
123
frontend/src/router/index.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import { useSettingsStore } from "../stores/settingsStore";
|
||||||
|
import LoginPage from "../pages/Login.vue";
|
||||||
|
import RegisterPage from "../pages/Register.vue";
|
||||||
|
|
||||||
|
const decodeBase64UrlUTF8 = (value) => {
|
||||||
|
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padding = normalized.length % 4;
|
||||||
|
const padded = padding === 0 ? normalized : `${normalized}${"=".repeat(4 - padding)}`;
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
};
|
||||||
|
const restoreOAuthSessionFromQuery = (query, authStore) => {
|
||||||
|
// Merge router query with URLSearchParams for full coverage
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const accessToken = query.access_token || query.accessToken || query.token || params.get("access_token") || params.get("accessToken") || params.get("token");
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const plainUserJSON = query.user_json || params.get("user_json");
|
||||||
|
const encodedUser = query.user || params.get("user");
|
||||||
|
const user = plainUserJSON ? JSON.parse(plainUserJSON) : encodedUser ? JSON.parse(decodeBase64UrlUTF8(encodedUser)) : null;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
authStore.setSession({ access_token: accessToken, user });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
name: "Login",
|
||||||
|
component: LoginPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/register",
|
||||||
|
name: "Register",
|
||||||
|
component: RegisterPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "Home",
|
||||||
|
component: () => import("../pages/Home.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/admin",
|
||||||
|
name: "Admin",
|
||||||
|
component: () => import("../pages/Admin.vue"),
|
||||||
|
meta: { requiresAuth: true, requiresAdminPermission: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/s/:spaceId",
|
||||||
|
name: "PublicSpace",
|
||||||
|
component: () => import("../pages/PublicSpace.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/s/:spaceId/n/:noteId",
|
||||||
|
name: "PublicNote",
|
||||||
|
component: () => import("../pages/PublicSpace.vue"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach(async (to, from, next) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
// Only attempt OAuth callback restoration if actual OAuth query params are present
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const hasOAuthParams = to.query.access_token || to.query.accessToken || to.query.token || params.get("access_token") || params.get("accessToken") || params.get("token");
|
||||||
|
|
||||||
|
if (to.path === "/login") {
|
||||||
|
if (hasOAuthParams) {
|
||||||
|
const restored = restoreOAuthSessionFromQuery(to.query, authStore);
|
||||||
|
if (restored) {
|
||||||
|
next({ path: "/", replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow login page to be viewed regardless of auth state if no OAuth callback
|
||||||
|
if (!hasOAuthParams) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.path === "/register") {
|
||||||
|
await settingsStore.loadFeatureFlags();
|
||||||
|
if (!settingsStore.registrationEnabled) {
|
||||||
|
next({ path: "/login", query: { message: "Registration is currently disabled." } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
|
next("/login");
|
||||||
|
} else if (to.meta.requiresAdminPermission && !authStore.isAdmin) {
|
||||||
|
next("/");
|
||||||
|
} else if ((to.path === "/login" || to.path === "/register") && authStore.isAuthenticated) {
|
||||||
|
next("/");
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
27
frontend/src/services/apiClient.js
Normal file
27
frontend/src/services/apiClient.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8080",
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
if (authStore.accessToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${authStore.accessToken}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
authStore.logout();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
108
frontend/src/stores/authStore.js
Normal file
108
frontend/src/stores/authStore.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import apiClient from "../services/apiClient";
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore("auth", () => {
|
||||||
|
const storedUser = localStorage.getItem("user");
|
||||||
|
const user = ref(storedUser ? JSON.parse(storedUser) : null);
|
||||||
|
const accessToken = ref(localStorage.getItem("accessToken"));
|
||||||
|
const isAuthenticated = computed(() => !!accessToken.value && !!user.value);
|
||||||
|
const isAdmin = computed(() => hasPermission("*") || hasPermission("admin.access"));
|
||||||
|
|
||||||
|
const normalizePermission = (permission) => (permission || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const permissionMatches = (pattern, permission) => {
|
||||||
|
const normalizedPattern = normalizePermission(pattern);
|
||||||
|
const normalizedPermission = normalizePermission(permission);
|
||||||
|
|
||||||
|
if (!normalizedPattern || !normalizedPermission) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizedPattern === "*" || normalizedPattern === normalizedPermission) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!normalizedPattern.includes("*")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const escaped = normalizedPattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
||||||
|
const regex = new RegExp(`^${escaped}$`);
|
||||||
|
return regex.test(normalizedPermission);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPermission = (permission) => {
|
||||||
|
const userPermissions = user.value?.permissions || [];
|
||||||
|
return userPermissions.some((pattern) => permissionMatches(pattern, permission));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSpacePermissionToken = (space) => space?.permission_key || "";
|
||||||
|
|
||||||
|
const hasSpacePermission = (space, action) => {
|
||||||
|
const token = getSpacePermissionToken(space);
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hasPermission(`space.${token}.${action}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSession = (responseData) => {
|
||||||
|
accessToken.value = responseData.access_token;
|
||||||
|
user.value = responseData.user;
|
||||||
|
localStorage.setItem("accessToken", accessToken.value);
|
||||||
|
localStorage.setItem("user", JSON.stringify(user.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (email, username, password, firstName = "", lastName = "") => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/api/v1/auth/register", {
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
password_confirm: password,
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSession(response.data);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data?.message || error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (email, password) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/api/v1/auth/login", {
|
||||||
|
email: email?.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSession(response.data);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data?.message || error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
accessToken.value = null;
|
||||||
|
user.value = null;
|
||||||
|
localStorage.removeItem("accessToken");
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
isAuthenticated,
|
||||||
|
isAdmin,
|
||||||
|
hasPermission,
|
||||||
|
hasSpacePermission,
|
||||||
|
setSession,
|
||||||
|
register,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
});
|
||||||
47
frontend/src/stores/settingsStore.js
Normal file
47
frontend/src/stores/settingsStore.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import apiClient from "../services/apiClient";
|
||||||
|
|
||||||
|
const DEFAULT_FLAGS = {
|
||||||
|
registration_enabled: true,
|
||||||
|
provider_login_enabled: true,
|
||||||
|
public_sharing_enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSettingsStore = defineStore("settings", () => {
|
||||||
|
const featureFlags = ref({ ...DEFAULT_FLAGS });
|
||||||
|
const flagsLoaded = ref(false);
|
||||||
|
|
||||||
|
const registrationEnabled = computed(() => !!featureFlags.value.registration_enabled);
|
||||||
|
const providerLoginEnabled = computed(() => !!featureFlags.value.provider_login_enabled);
|
||||||
|
const publicSharingEnabled = computed(() => !!featureFlags.value.public_sharing_enabled);
|
||||||
|
|
||||||
|
const loadFeatureFlags = async (force = false) => {
|
||||||
|
if (flagsLoaded.value && !force) {
|
||||||
|
return featureFlags.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/api/v1/settings/feature-flags");
|
||||||
|
featureFlags.value = {
|
||||||
|
...DEFAULT_FLAGS,
|
||||||
|
...response.data,
|
||||||
|
};
|
||||||
|
flagsLoaded.value = true;
|
||||||
|
} catch {
|
||||||
|
featureFlags.value = { ...DEFAULT_FLAGS };
|
||||||
|
flagsLoaded.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return featureFlags.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
featureFlags,
|
||||||
|
flagsLoaded,
|
||||||
|
registrationEnabled,
|
||||||
|
providerLoginEnabled,
|
||||||
|
publicSharingEnabled,
|
||||||
|
loadFeatureFlags,
|
||||||
|
};
|
||||||
|
});
|
||||||
224
frontend/src/stores/spaceStore.js
Normal file
224
frontend/src/stores/spaceStore.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import apiClient from "../services/apiClient";
|
||||||
|
|
||||||
|
export const useSpaceStore = defineStore("space", () => {
|
||||||
|
const spaces = ref([]);
|
||||||
|
const currentSpace = ref(null);
|
||||||
|
const notes = ref([]);
|
||||||
|
const notesSkip = ref(0);
|
||||||
|
const notesLimit = ref(20);
|
||||||
|
const notesHasMore = ref(true);
|
||||||
|
const notesLoading = ref(false);
|
||||||
|
const categories = ref([]);
|
||||||
|
const categoryTree = ref([]);
|
||||||
|
|
||||||
|
const refreshSpaceData = async (spaceId) => {
|
||||||
|
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSpaces = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/api/v1/spaces");
|
||||||
|
spaces.value = response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching spaces:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSpace = async (spaceId) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/api/v1/spaces/${spaceId}`);
|
||||||
|
currentSpace.value = response.data;
|
||||||
|
await refreshSpaceData(spaceId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error selecting space:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCategories = async (spaceId) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/categories`);
|
||||||
|
categoryTree.value = response.data || [];
|
||||||
|
categories.value = categoryTree.value;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching categories:", error);
|
||||||
|
categoryTree.value = [];
|
||||||
|
categories.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchNotes = async (spaceId, options = {}) => {
|
||||||
|
const { reset = true, limit = notesLimit.value } = options;
|
||||||
|
if (!spaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (notesLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!reset && !notesHasMore.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
notesSkip.value = 0;
|
||||||
|
notesHasMore.value = true;
|
||||||
|
notesLimit.value = limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
notesLoading.value = true;
|
||||||
|
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/notes`, {
|
||||||
|
params: {
|
||||||
|
skip: notesSkip.value,
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fetchedNotes = response.data || [];
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
notes.value = fetchedNotes;
|
||||||
|
} else {
|
||||||
|
notes.value = [...notes.value, ...fetchedNotes];
|
||||||
|
}
|
||||||
|
|
||||||
|
notesSkip.value += fetchedNotes.length;
|
||||||
|
notesHasMore.value = fetchedNotes.length === limit;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching notes:", error);
|
||||||
|
} finally {
|
||||||
|
notesLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreNotes = async (spaceId) => {
|
||||||
|
await fetchNotes(spaceId, { reset: false, limit: notesLimit.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSpace = async (spaceData) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/api/v1/spaces", spaceData);
|
||||||
|
spaces.value.push(response.data);
|
||||||
|
currentSpace.value = response.data;
|
||||||
|
localStorage.setItem("currentSpaceId", response.data.id);
|
||||||
|
await refreshSpaceData(response.data.id);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data?.message || error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSpace = async (spaceId, spaceData) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/api/v1/spaces/${spaceId}`, spaceData);
|
||||||
|
const index = spaces.value.findIndex((s) => s.id === spaceId);
|
||||||
|
if (index !== -1) {
|
||||||
|
spaces.value[index] = response.data;
|
||||||
|
}
|
||||||
|
if (currentSpace.value?.id === spaceId) {
|
||||||
|
currentSpace.value = response.data;
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data?.message || error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCategory = async (spaceId, categoryData) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/categories`, categoryData);
|
||||||
|
await fetchCategories(spaceId);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data?.message || error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCategory = async (spaceId, categoryId, categoryData) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/categories/${categoryId}`, categoryData);
|
||||||
|
await fetchCategories(spaceId);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data?.message || error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCategory = async (spaceId, categoryId) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/v1/spaces/${spaceId}/categories/${categoryId}`);
|
||||||
|
await refreshSpaceData(spaceId);
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data?.message || error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNote = async (spaceId, noteData) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/notes`, noteData);
|
||||||
|
await refreshSpaceData(spaceId);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data?.message || error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNote = async (spaceId, noteData) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/notes/${noteData.id}`, noteData);
|
||||||
|
const index = notes.value.findIndex((n) => n.id === noteData.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
notes.value[index] = response.data;
|
||||||
|
}
|
||||||
|
await fetchCategories(spaceId);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data?.message || error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteNote = async (spaceId, noteId) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/v1/spaces/${spaceId}/notes/${noteId}`);
|
||||||
|
notes.value = notes.value.filter((n) => n.id !== noteId);
|
||||||
|
await fetchCategories(spaceId);
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data?.message || error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchNotes = async (query) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/search`, { params: { q: query } });
|
||||||
|
notes.value = response.data || [];
|
||||||
|
notesHasMore.value = false;
|
||||||
|
notesSkip.value = notes.value.length;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error searching notes:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
spaces,
|
||||||
|
currentSpace,
|
||||||
|
notes,
|
||||||
|
notesHasMore,
|
||||||
|
notesLoading,
|
||||||
|
categories,
|
||||||
|
categoryTree,
|
||||||
|
fetchSpaces,
|
||||||
|
selectSpace,
|
||||||
|
fetchNotes,
|
||||||
|
loadMoreNotes,
|
||||||
|
fetchCategories,
|
||||||
|
createSpace,
|
||||||
|
updateSpace,
|
||||||
|
createCategory,
|
||||||
|
updateCategory,
|
||||||
|
deleteCategory,
|
||||||
|
createNote,
|
||||||
|
updateNote,
|
||||||
|
deleteNote,
|
||||||
|
searchNotes,
|
||||||
|
};
|
||||||
|
});
|
||||||
17
frontend/src/utils/noteSort.js
Normal file
17
frontend/src/utils/noteSort.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export const compareNotesByPriority = (a, b) => {
|
||||||
|
const pinnedDelta = (b?.is_pinned ? 1 : 0) - (a?.is_pinned ? 1 : 0);
|
||||||
|
if (pinnedDelta !== 0) {
|
||||||
|
return pinnedDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const featuredDelta = (b?.is_favorite || b?.is_featured ? 1 : 0) - (a?.is_favorite || a?.is_featured ? 1 : 0);
|
||||||
|
if (featuredDelta !== 0) {
|
||||||
|
return featuredDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftTitle = (a?.title || "").trim();
|
||||||
|
const rightTitle = (b?.title || "").trim();
|
||||||
|
return leftTitle.localeCompare(rightTitle, undefined, { sensitivity: "base" });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sortNotesByPriority = (notes = []) => [...notes].sort(compareNotesByPriority);
|
||||||
39
frontend/tests/auth.spec.js
Normal file
39
frontend/tests/auth.spec.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { useAuthStore } from "../../src/stores/authStore";
|
||||||
|
import { createPinia, setActivePinia } from "pinia";
|
||||||
|
|
||||||
|
describe("Auth Store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize with no user", () => {
|
||||||
|
const store = useAuthStore();
|
||||||
|
expect(store.isAuthenticated).toBe(false);
|
||||||
|
expect(store.user).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should store user data on login", () => {
|
||||||
|
const store = useAuthStore();
|
||||||
|
|
||||||
|
// Mock user data
|
||||||
|
const mockUser = {
|
||||||
|
id: "123",
|
||||||
|
email: "test@example.com",
|
||||||
|
username: "testuser",
|
||||||
|
};
|
||||||
|
|
||||||
|
// In a real test, you'd mock the API call
|
||||||
|
// For now, just test the store structure
|
||||||
|
expect(store.user).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear user data on logout", () => {
|
||||||
|
const store = useAuthStore();
|
||||||
|
store.logout();
|
||||||
|
|
||||||
|
expect(store.isAuthenticated).toBe(false);
|
||||||
|
expect(store.user).toBeNull();
|
||||||
|
expect(store.accessToken).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
frontend/vite.config.js
Normal file
20
frontend/vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8080",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
assetsDir: "assets",
|
||||||
|
},
|
||||||
|
});
|
||||||
14
frontend/vitest.config.js
Normal file
14
frontend/vitest.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// vitest.config.js
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "jsdom",
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user