first commit

This commit is contained in:
domrichardson
2026-03-24 16:03:04 +00:00
commit df40cc57e1
80 changed files with 16766 additions and 0 deletions

22
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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=

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

View 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(&note)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("note not found")
}
return nil, err
}
return &note, 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, &notes); 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, &notes); 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, &notes); 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, &notes); 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
}

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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/...")
}

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

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

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

View 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
View 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");

View 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&#10;space.project_docs.category.create&#10;space.project_docs.*"
:disabled="isEditingSystemGroup"
></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="closeGroupModal">Cancel</button>
<button v-if="!isEditingSystemGroup" type="submit" class="btn btn-primary" :disabled="submittingGroupModal">
{{ submittingGroupModal ? "Saving..." : groupModalMode === "create" ? "Create Group" : "Save Changes" }}
</button>
</div>
</form>
</div>
</div>
</div>
<div v-if="showGroupModal" class="modal-backdrop fade show"></div>
</teleport>
</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>

View File

@@ -0,0 +1,7 @@
<template>
<div class="home-page">
<router-view />
</div>
</template>
<script setup></script>

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

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

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

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

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

View 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,
};
});

View 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,
};
});

View 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,
};
});

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

View 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
View 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
View 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",
},
},
});