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